edit.go

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