1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "charm.land/fantasy"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/fsext"
17 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/permission"
20)
21
22type MultiEditOperation struct {
23 OldString string `json:"old_string" description:"The text to replace"`
24 NewString string `json:"new_string" description:"The text to replace it with"`
25 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
26}
27
28type MultiEditParams struct {
29 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
30 Edits []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
31}
32
33type MultiEditPermissionsParams struct {
34 FilePath string `json:"file_path"`
35 OldContent string `json:"old_content,omitempty"`
36 NewContent string `json:"new_content,omitempty"`
37}
38
39type MultiEditResponseMetadata struct {
40 Additions int `json:"additions"`
41 Removals int `json:"removals"`
42 OldContent string `json:"old_content,omitempty"`
43 NewContent string `json:"new_content,omitempty"`
44 EditsApplied int `json:"edits_applied"`
45}
46
47const MultiEditToolName = "multiedit"
48
49//go:embed multiedit.md
50var multieditDescription []byte
51
52func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
53 return fantasy.NewAgentTool(
54 MultiEditToolName,
55 string(multieditDescription),
56 func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
57 if params.FilePath == "" {
58 return fantasy.NewTextErrorResponse("file_path is required"), nil
59 }
60
61 if len(params.Edits) == 0 {
62 return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
63 }
64
65 if !filepath.IsAbs(params.FilePath) {
66 params.FilePath = filepath.Join(workingDir, params.FilePath)
67 }
68
69 // Validate all edits before applying any
70 if err := validateEdits(params.Edits); err != nil {
71 return fantasy.NewTextErrorResponse(err.Error()), nil
72 }
73
74 var response fantasy.ToolResponse
75 var err error
76
77 editCtx := editContext{ctx, permissions, files, workingDir}
78 // Handle file creation case (first edit has empty old_string)
79 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
80 response, err = processMultiEditWithCreation(editCtx, params, call)
81 } else {
82 response, err = processMultiEditExistingFile(editCtx, params, call)
83 }
84
85 if err != nil {
86 return response, err
87 }
88
89 if response.IsError {
90 return response, nil
91 }
92
93 // Notify LSP clients about the change
94 notifyLSPs(ctx, lspClients, params.FilePath)
95
96 // Wait for LSP diagnostics and add them to the response
97 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
98 text += getDiagnostics(params.FilePath, lspClients)
99 response.Content = text
100 return response, nil
101 })
102}
103
104func validateEdits(edits []MultiEditOperation) error {
105 for i, edit := range edits {
106 if edit.OldString == edit.NewString {
107 return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
108 }
109 // Only the first edit can have empty old_string (for file creation)
110 if i > 0 && edit.OldString == "" {
111 return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
112 }
113 }
114 return nil
115}
116
117func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
118 // First edit creates the file
119 firstEdit := params.Edits[0]
120 if firstEdit.OldString != "" {
121 return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
122 }
123
124 // Check if file already exists
125 if _, err := os.Stat(params.FilePath); err == nil {
126 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
127 } else if !os.IsNotExist(err) {
128 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
129 }
130
131 // Create parent directories
132 dir := filepath.Dir(params.FilePath)
133 if err := os.MkdirAll(dir, 0o755); err != nil {
134 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
135 }
136
137 // Start with the content from the first edit
138 currentContent := firstEdit.NewString
139
140 // Apply remaining edits to the content
141 for i := 1; i < len(params.Edits); i++ {
142 edit := params.Edits[i]
143 newContent, err := applyEditToContent(currentContent, edit)
144 if err != nil {
145 return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
146 }
147 currentContent = newContent
148 }
149
150 // Get session and message IDs
151 sessionID := GetSessionFromContext(edit.ctx)
152 if sessionID == "" {
153 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
154 }
155
156 // Check permissions
157 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
158
159 p := edit.permissions.Request(permission.CreatePermissionRequest{
160 SessionID: sessionID,
161 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
162 ToolCallID: call.ID,
163 ToolName: MultiEditToolName,
164 Action: "write",
165 Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
166 Params: MultiEditPermissionsParams{
167 FilePath: params.FilePath,
168 OldContent: "",
169 NewContent: currentContent,
170 },
171 })
172 if !p {
173 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
174 }
175
176 // Write the file
177 err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
178 if err != nil {
179 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
180 }
181
182 // Update file history
183 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
184 if err != nil {
185 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
186 }
187
188 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
189 if err != nil {
190 slog.Debug("Error creating file history version", "error", err)
191 }
192
193 recordFileWrite(params.FilePath)
194 recordFileRead(params.FilePath)
195
196 return fantasy.WithResponseMetadata(
197 fantasy.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
198 MultiEditResponseMetadata{
199 OldContent: "",
200 NewContent: currentContent,
201 Additions: additions,
202 Removals: removals,
203 EditsApplied: len(params.Edits),
204 },
205 ), nil
206}
207
208func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
209 // Validate file exists and is readable
210 fileInfo, err := os.Stat(params.FilePath)
211 if err != nil {
212 if os.IsNotExist(err) {
213 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
214 }
215 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
216 }
217
218 if fileInfo.IsDir() {
219 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
220 }
221
222 // Check if file was read before editing
223 if getLastReadTime(params.FilePath).IsZero() {
224 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
225 }
226
227 // Check if file was modified since last read
228 modTime := fileInfo.ModTime()
229 lastRead := getLastReadTime(params.FilePath)
230 if modTime.After(lastRead) {
231 return fantasy.NewTextErrorResponse(
232 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
233 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
234 )), nil
235 }
236
237 // Read current file content
238 content, err := os.ReadFile(params.FilePath)
239 if err != nil {
240 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
241 }
242
243 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
244 currentContent := oldContent
245
246 // Apply all edits sequentially
247 for i, edit := range params.Edits {
248 newContent, err := applyEditToContent(currentContent, edit)
249 if err != nil {
250 return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
251 }
252 currentContent = newContent
253 }
254
255 // Check if content actually changed
256 if oldContent == currentContent {
257 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
258 }
259
260 // Get session and message IDs
261 sessionID := GetSessionFromContext(edit.ctx)
262 if sessionID == "" {
263 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
264 }
265
266 // Generate diff and check permissions
267 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
268 p := edit.permissions.Request(permission.CreatePermissionRequest{
269 SessionID: sessionID,
270 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
271 ToolCallID: call.ID,
272 ToolName: MultiEditToolName,
273 Action: "write",
274 Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
275 Params: MultiEditPermissionsParams{
276 FilePath: params.FilePath,
277 OldContent: oldContent,
278 NewContent: currentContent,
279 },
280 })
281 if !p {
282 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
283 }
284
285 if isCrlf {
286 currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
287 }
288
289 // Write the updated content
290 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
291 if err != nil {
292 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
293 }
294
295 // Update file history
296 file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
297 if err != nil {
298 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
299 if err != nil {
300 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
301 }
302 }
303 if file.Content != oldContent {
304 // User manually changed the content, store an intermediate version
305 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
306 if err != nil {
307 slog.Debug("Error creating file history version", "error", err)
308 }
309 }
310
311 // Store the new version
312 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
313 if err != nil {
314 slog.Debug("Error creating file history version", "error", err)
315 }
316
317 recordFileWrite(params.FilePath)
318 recordFileRead(params.FilePath)
319
320 return fantasy.WithResponseMetadata(
321 fantasy.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
322 MultiEditResponseMetadata{
323 OldContent: oldContent,
324 NewContent: currentContent,
325 Additions: additions,
326 Removals: removals,
327 EditsApplied: len(params.Edits),
328 },
329 ), nil
330}
331
332func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
333 if edit.OldString == "" && edit.NewString == "" {
334 return content, nil
335 }
336
337 if edit.OldString == "" {
338 return "", fmt.Errorf("old_string cannot be empty for content replacement")
339 }
340
341 var newContent string
342 var replacementCount int
343
344 if edit.ReplaceAll {
345 newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
346 replacementCount = strings.Count(content, edit.OldString)
347 if replacementCount == 0 {
348 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
349 }
350 } else {
351 index := strings.Index(content, edit.OldString)
352 if index == -1 {
353 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
354 }
355
356 lastIndex := strings.LastIndex(content, edit.OldString)
357 if index != lastIndex {
358 return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
359 }
360
361 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
362 replacementCount = 1
363 }
364
365 return newContent, nil
366}