edit.go

  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}