1package tools
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/charmbracelet/crush/internal/ai"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/permission"
18)
19
20type EditParams struct {
21 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
22 OldString string `json:"old_string" description:"The text to replace"`
23 NewString string `json:"new_string" description:"The text to replace it with"`
24 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
25}
26
27type EditPermissionsParams struct {
28 FilePath string `json:"file_path"`
29 OldContent string `json:"old_content,omitempty"`
30 NewContent string `json:"new_content,omitempty"`
31}
32
33type EditResponseMetadata struct {
34 Additions int `json:"additions"`
35 Removals int `json:"removals"`
36 OldContent string `json:"old_content,omitempty"`
37 NewContent string `json:"new_content,omitempty"`
38}
39
40const (
41 EditToolName = "edit"
42)
43
44func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) ai.AgentTool {
45 return ai.NewTypedToolFunc(
46 EditToolName,
47 `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
48
49Before using this tool:
50
511. Use the FileRead tool to understand the file's contents and context
52
532. Verify the directory path is correct (only applicable when creating new files):
54 - Use the LS tool to verify the parent directory exists and is the correct location
55
56To make a file edit, provide the following:
571. file_path: The absolute path to the file to modify (must be absolute, not relative)
582. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
593. new_string: The edited text to replace the old_string
604. replace_all: Replace all occurrences of old_string (default false)
61
62Special cases:
63- To create a new file: provide file_path and new_string, leave old_string empty
64- To delete content: provide file_path and old_string, leave new_string empty
65
66The tool will replace ONE occurrence of old_string with new_string in the specified file by default. Set replace_all to true to replace all occurrences.
67
68CRITICAL REQUIREMENTS FOR USING THIS TOOL:
69
701. UNIQUENESS: When replace_all is false (default), the old_string MUST uniquely identify the specific instance you want to change. This means:
71 - Include AT LEAST 3-5 lines of context BEFORE the change point
72 - Include AT LEAST 3-5 lines of context AFTER the change point
73 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
74
752. SINGLE INSTANCE: When replace_all is false, this tool can only change ONE instance at a time. If you need to change multiple instances:
76 - Set replace_all to true to replace all occurrences at once
77 - Or make separate calls to this tool for each instance
78 - Each call must uniquely identify its specific instance using extensive context
79
803. VERIFICATION: Before using this tool:
81 - Check how many instances of the target text exist in the file
82 - If multiple instances exist and replace_all is false, gather enough context to uniquely identify each one
83 - Plan separate tool calls for each instance or use replace_all
84
85WARNING: If you do not follow these requirements:
86 - The tool will fail if old_string matches multiple locations and replace_all is false
87 - The tool will fail if old_string doesn't match exactly (including whitespace)
88 - You may change the wrong instance if you don't include enough context
89
90When making edits:
91 - Ensure the edit results in idiomatic, correct code
92 - Do not leave the code in a broken state
93 - Always use absolute file paths (starting with /)
94
95WINDOWS NOTES:
96- File paths should use forward slashes (/) for cross-platform compatibility
97- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
98- File permissions are handled automatically by the Go runtime
99- Always assumes \n for line endings. The tool will handle \r\n conversion automatically if needed.
100
101Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`,
102 func(ctx context.Context, params EditParams, call ai.ToolCall) (ai.ToolResponse, error) {
103 if params.FilePath == "" {
104 return ai.NewTextErrorResponse("file_path is required"), nil
105 }
106
107 if !filepath.IsAbs(params.FilePath) {
108 params.FilePath = filepath.Join(workingDir, params.FilePath)
109 }
110
111 var response ai.ToolResponse
112 var err error
113
114 if params.OldString == "" {
115 response, err = createNewFile(ctx, permissions, files, workingDir, params.FilePath, params.NewString, call)
116 if err != nil {
117 return response, err
118 }
119 }
120
121 if params.NewString == "" {
122 response, err = deleteContent(ctx, permissions, files, workingDir, params.FilePath, params.OldString, params.ReplaceAll, call)
123 if err != nil {
124 return response, err
125 }
126 }
127
128 response, err = replaceContent(ctx, permissions, files, workingDir, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
129 if err != nil {
130 return response, err
131 }
132 if response.IsError {
133 // Return early if there was an error during content replacement
134 // This prevents unnecessary LSP diagnostics processing
135 return response, nil
136 }
137
138 waitForLspDiagnostics(ctx, params.FilePath, lspClients)
139 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
140 text += getDiagnostics(params.FilePath, lspClients)
141 response.Content = text
142 return response, nil
143 },
144 )
145}
146
147func createNewFile(ctx context.Context, permissions permission.Service, files history.Service, workingDir, filePath, content string, call ai.ToolCall) (ai.ToolResponse, error) {
148 fileInfo, err := os.Stat(filePath)
149 if err == nil {
150 if fileInfo.IsDir() {
151 return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
152 }
153 return ai.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
154 } else if !os.IsNotExist(err) {
155 return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
156 }
157
158 dir := filepath.Dir(filePath)
159 if err = os.MkdirAll(dir, 0o755); err != nil {
160 return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
161 }
162
163 sessionID, messageID := GetContextValues(ctx)
164 if sessionID == "" || messageID == "" {
165 return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
166 }
167
168 _, additions, removals := diff.GenerateDiff(
169 "",
170 content,
171 strings.TrimPrefix(filePath, workingDir),
172 )
173 p := permissions.Request(
174 permission.CreatePermissionRequest{
175 SessionID: sessionID,
176 Path: fsext.PathOrPrefix(filePath, workingDir),
177 ToolCallID: call.ID,
178 ToolName: EditToolName,
179 Action: "write",
180 Description: fmt.Sprintf("Create file %s", filePath),
181 Params: EditPermissionsParams{
182 FilePath: filePath,
183 OldContent: "",
184 NewContent: content,
185 },
186 },
187 )
188 if !p {
189 return ai.ToolResponse{}, permission.ErrorPermissionDenied
190 }
191
192 err = os.WriteFile(filePath, []byte(content), 0o644)
193 if err != nil {
194 return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
195 }
196
197 // File can't be in the history so we create a new file history
198 _, err = files.Create(ctx, sessionID, filePath, "")
199 if err != nil {
200 // Log error but don't fail the operation
201 return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
202 }
203
204 // Add the new content to the file history
205 _, err = files.CreateVersion(ctx, sessionID, filePath, content)
206 if err != nil {
207 // Log error but don't fail the operation
208 slog.Debug("Error creating file history version", "error", err)
209 }
210
211 recordFileWrite(filePath)
212 recordFileRead(filePath)
213
214 return ai.WithResponseMetadata(
215 ai.NewTextResponse("File created: "+filePath),
216 EditResponseMetadata{
217 OldContent: "",
218 NewContent: content,
219 Additions: additions,
220 Removals: removals,
221 },
222 ), nil
223}
224
225func deleteContent(ctx context.Context, permissions permission.Service, files history.Service, workingDir, filePath, oldString string, replaceAll bool, call ai.ToolCall) (ai.ToolResponse, error) {
226 fileInfo, err := os.Stat(filePath)
227 if err != nil {
228 if os.IsNotExist(err) {
229 return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
230 }
231 return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
232 }
233
234 if fileInfo.IsDir() {
235 return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
236 }
237
238 if getLastReadTime(filePath).IsZero() {
239 return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
240 }
241
242 modTime := fileInfo.ModTime()
243 lastRead := getLastReadTime(filePath)
244 if modTime.After(lastRead) {
245 return ai.NewTextErrorResponse(
246 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
247 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
248 )), nil
249 }
250
251 content, err := os.ReadFile(filePath)
252 if err != nil {
253 return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
254 }
255
256 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
257
258 var newContent string
259 var deletionCount int
260
261 if replaceAll {
262 newContent = strings.ReplaceAll(oldContent, oldString, "")
263 deletionCount = strings.Count(oldContent, oldString)
264 if deletionCount == 0 {
265 return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
266 }
267 } else {
268 index := strings.Index(oldContent, oldString)
269 if index == -1 {
270 return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
271 }
272
273 lastIndex := strings.LastIndex(oldContent, oldString)
274 if index != lastIndex {
275 return ai.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
276 }
277
278 newContent = oldContent[:index] + oldContent[index+len(oldString):]
279 deletionCount = 1
280 }
281
282 sessionID, messageID := GetContextValues(ctx)
283
284 if sessionID == "" || messageID == "" {
285 return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
286 }
287
288 _, additions, removals := diff.GenerateDiff(
289 oldContent,
290 newContent,
291 strings.TrimPrefix(filePath, workingDir),
292 )
293
294 p := permissions.Request(
295 permission.CreatePermissionRequest{
296 SessionID: sessionID,
297 Path: fsext.PathOrPrefix(filePath, workingDir),
298 ToolCallID: call.ID,
299 ToolName: EditToolName,
300 Action: "write",
301 Description: fmt.Sprintf("Delete content from file %s", filePath),
302 Params: EditPermissionsParams{
303 FilePath: filePath,
304 OldContent: oldContent,
305 NewContent: newContent,
306 },
307 },
308 )
309 if !p {
310 return ai.ToolResponse{}, permission.ErrorPermissionDenied
311 }
312
313 if isCrlf {
314 newContent, _ = fsext.ToWindowsLineEndings(newContent)
315 }
316
317 err = os.WriteFile(filePath, []byte(newContent), 0o644)
318 if err != nil {
319 return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
320 }
321
322 // Check if file exists in history
323 file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
324 if err != nil {
325 _, err = files.Create(ctx, sessionID, filePath, oldContent)
326 if err != nil {
327 // Log error but don't fail the operation
328 return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
329 }
330 }
331 if file.Content != oldContent {
332 // User Manually changed the content store an intermediate version
333 _, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
334 if err != nil {
335 slog.Debug("Error creating file history version", "error", err)
336 }
337 }
338 // Store the new version
339 _, err = files.CreateVersion(ctx, sessionID, filePath, "")
340 if err != nil {
341 slog.Debug("Error creating file history version", "error", err)
342 }
343
344 recordFileWrite(filePath)
345 recordFileRead(filePath)
346
347 return ai.WithResponseMetadata(
348 ai.NewTextResponse("Content deleted from file: "+filePath),
349 EditResponseMetadata{
350 OldContent: oldContent,
351 NewContent: newContent,
352 Additions: additions,
353 Removals: removals,
354 },
355 ), nil
356}
357
358func replaceContent(ctx context.Context, permissions permission.Service, files history.Service, workingDir, filePath, oldString, newString string, replaceAll bool, call ai.ToolCall) (ai.ToolResponse, error) {
359 fileInfo, err := os.Stat(filePath)
360 if err != nil {
361 if os.IsNotExist(err) {
362 return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
363 }
364 return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
365 }
366
367 if fileInfo.IsDir() {
368 return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
369 }
370
371 if getLastReadTime(filePath).IsZero() {
372 return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
373 }
374
375 modTime := fileInfo.ModTime()
376 lastRead := getLastReadTime(filePath)
377 if modTime.After(lastRead) {
378 return ai.NewTextErrorResponse(
379 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
380 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
381 )), nil
382 }
383
384 content, err := os.ReadFile(filePath)
385 if err != nil {
386 return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
387 }
388
389 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
390
391 var newContent string
392 var replacementCount int
393
394 if replaceAll {
395 newContent = strings.ReplaceAll(oldContent, oldString, newString)
396 replacementCount = strings.Count(oldContent, oldString)
397 if replacementCount == 0 {
398 return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
399 }
400 } else {
401 index := strings.Index(oldContent, oldString)
402 if index == -1 {
403 return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
404 }
405
406 lastIndex := strings.LastIndex(oldContent, oldString)
407 if index != lastIndex {
408 return ai.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
409 }
410
411 newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
412 replacementCount = 1
413 }
414
415 if oldContent == newContent {
416 return ai.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
417 }
418 sessionID, messageID := GetContextValues(ctx)
419
420 if sessionID == "" || messageID == "" {
421 return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
422 }
423 _, additions, removals := diff.GenerateDiff(
424 oldContent,
425 newContent,
426 strings.TrimPrefix(filePath, workingDir),
427 )
428
429 p := permissions.Request(
430 permission.CreatePermissionRequest{
431 SessionID: sessionID,
432 Path: fsext.PathOrPrefix(filePath, workingDir),
433 ToolCallID: call.ID,
434 ToolName: EditToolName,
435 Action: "write",
436 Description: fmt.Sprintf("Replace content in file %s", filePath),
437 Params: EditPermissionsParams{
438 FilePath: filePath,
439 OldContent: oldContent,
440 NewContent: newContent,
441 },
442 },
443 )
444 if !p {
445 return ai.ToolResponse{}, permission.ErrorPermissionDenied
446 }
447
448 if isCrlf {
449 newContent, _ = fsext.ToWindowsLineEndings(newContent)
450 }
451
452 err = os.WriteFile(filePath, []byte(newContent), 0o644)
453 if err != nil {
454 return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
455 }
456
457 // Check if file exists in history
458 file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
459 if err != nil {
460 _, err = files.Create(ctx, sessionID, filePath, oldContent)
461 if err != nil {
462 // Log error but don't fail the operation
463 return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
464 }
465 }
466 if file.Content != oldContent {
467 // User Manually changed the content store an intermediate version
468 _, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
469 if err != nil {
470 slog.Debug("Error creating file history version", "error", err)
471 }
472 }
473 // Store the new version
474 _, err = files.CreateVersion(ctx, sessionID, filePath, newContent)
475 if err != nil {
476 slog.Debug("Error creating file history version", "error", err)
477 }
478
479 recordFileWrite(filePath)
480 recordFileRead(filePath)
481
482 return ai.WithResponseMetadata(
483 ai.NewTextResponse("Content replaced in file: "+filePath),
484 EditResponseMetadata{
485 OldContent: oldContent,
486 NewContent: newContent,
487 Additions: additions,
488 Removals: removals,
489 }), nil
490}