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) Name() string {
117	return EditToolName
118}
119
120func (e *editTool) Info() ToolInfo {
121	return ToolInfo{
122		Name:        EditToolName,
123		Description: editDescription,
124		Parameters: map[string]any{
125			"file_path": map[string]any{
126				"type":        "string",
127				"description": "The absolute path to the file to modify",
128			},
129			"old_string": map[string]any{
130				"type":        "string",
131				"description": "The text to replace",
132			},
133			"new_string": map[string]any{
134				"type":        "string",
135				"description": "The text to replace it with",
136			},
137			"replace_all": map[string]any{
138				"type":        "boolean",
139				"description": "Replace all occurrences of old_string (default false)",
140			},
141		},
142		Required: []string{"file_path", "old_string", "new_string"},
143	}
144}
145
146func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
147	var params EditParams
148	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
149		return NewTextErrorResponse("invalid parameters"), nil
150	}
151
152	if params.FilePath == "" {
153		return NewTextErrorResponse("file_path is required"), nil
154	}
155
156	if !filepath.IsAbs(params.FilePath) {
157		params.FilePath = filepath.Join(e.workingDir, params.FilePath)
158	}
159
160	var response ToolResponse
161	var err error
162
163	if params.OldString == "" {
164		response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
165		if err != nil {
166			return response, err
167		}
168	}
169
170	if params.NewString == "" {
171		response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
172		if err != nil {
173			return response, err
174		}
175	}
176
177	response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
178	if err != nil {
179		return response, err
180	}
181	if response.IsError {
182		// Return early if there was an error during content replacement
183		// This prevents unnecessary LSP diagnostics processing
184		return response, nil
185	}
186
187	notifyLSPs(ctx, e.lspClients, params.FilePath)
188
189	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
190	text += getDiagnostics(params.FilePath, e.lspClients)
191	response.Content = text
192	return response, nil
193}
194
195func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
196	fileInfo, err := os.Stat(filePath)
197	if err == nil {
198		if fileInfo.IsDir() {
199			return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
200		}
201		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
202	} else if !os.IsNotExist(err) {
203		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
204	}
205
206	dir := filepath.Dir(filePath)
207	if err = os.MkdirAll(dir, 0o755); err != nil {
208		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
209	}
210
211	sessionID, messageID := GetContextValues(ctx)
212	if sessionID == "" || messageID == "" {
213		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
214	}
215
216	_, additions, removals := diff.GenerateDiff(
217		"",
218		content,
219		strings.TrimPrefix(filePath, e.workingDir),
220	)
221	p := e.permissions.Request(
222		permission.CreatePermissionRequest{
223			SessionID:   sessionID,
224			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
225			ToolCallID:  call.ID,
226			ToolName:    EditToolName,
227			Action:      "write",
228			Description: fmt.Sprintf("Create file %s", filePath),
229			Params: EditPermissionsParams{
230				FilePath:   filePath,
231				OldContent: "",
232				NewContent: content,
233			},
234		},
235	)
236	if !p {
237		return ToolResponse{}, permission.ErrorPermissionDenied
238	}
239
240	err = os.WriteFile(filePath, []byte(content), 0o644)
241	if err != nil {
242		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
243	}
244
245	// File can't be in the history so we create a new file history
246	_, err = e.files.Create(ctx, sessionID, filePath, "")
247	if err != nil {
248		// Log error but don't fail the operation
249		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
250	}
251
252	// Add the new content to the file history
253	_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
254	if err != nil {
255		// Log error but don't fail the operation
256		slog.Debug("Error creating file history version", "error", err)
257	}
258
259	recordFileWrite(filePath)
260	recordFileRead(filePath)
261
262	return WithResponseMetadata(
263		NewTextResponse("File created: "+filePath),
264		EditResponseMetadata{
265			OldContent: "",
266			NewContent: content,
267			Additions:  additions,
268			Removals:   removals,
269		},
270	), nil
271}
272
273func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
274	fileInfo, err := os.Stat(filePath)
275	if err != nil {
276		if os.IsNotExist(err) {
277			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
278		}
279		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
280	}
281
282	if fileInfo.IsDir() {
283		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
284	}
285
286	if getLastReadTime(filePath).IsZero() {
287		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
288	}
289
290	modTime := fileInfo.ModTime()
291	lastRead := getLastReadTime(filePath)
292	if modTime.After(lastRead) {
293		return NewTextErrorResponse(
294			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
295				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
296			)), nil
297	}
298
299	content, err := os.ReadFile(filePath)
300	if err != nil {
301		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
302	}
303
304	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
305
306	var newContent string
307	var deletionCount int
308
309	if replaceAll {
310		newContent = strings.ReplaceAll(oldContent, oldString, "")
311		deletionCount = strings.Count(oldContent, oldString)
312		if deletionCount == 0 {
313			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
314		}
315	} else {
316		index := strings.Index(oldContent, oldString)
317		if index == -1 {
318			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
319		}
320
321		lastIndex := strings.LastIndex(oldContent, oldString)
322		if index != lastIndex {
323			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
324		}
325
326		newContent = oldContent[:index] + oldContent[index+len(oldString):]
327		deletionCount = 1
328	}
329
330	sessionID, messageID := GetContextValues(ctx)
331
332	if sessionID == "" || messageID == "" {
333		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
334	}
335
336	_, additions, removals := diff.GenerateDiff(
337		oldContent,
338		newContent,
339		strings.TrimPrefix(filePath, e.workingDir),
340	)
341
342	p := e.permissions.Request(
343		permission.CreatePermissionRequest{
344			SessionID:   sessionID,
345			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
346			ToolCallID:  call.ID,
347			ToolName:    EditToolName,
348			Action:      "write",
349			Description: fmt.Sprintf("Delete content from file %s", filePath),
350			Params: EditPermissionsParams{
351				FilePath:   filePath,
352				OldContent: oldContent,
353				NewContent: newContent,
354			},
355		},
356	)
357	if !p {
358		return ToolResponse{}, permission.ErrorPermissionDenied
359	}
360
361	if isCrlf {
362		newContent, _ = fsext.ToWindowsLineEndings(newContent)
363	}
364
365	err = os.WriteFile(filePath, []byte(newContent), 0o644)
366	if err != nil {
367		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
368	}
369
370	// Check if file exists in history
371	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
372	if err != nil {
373		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
374		if err != nil {
375			// Log error but don't fail the operation
376			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
377		}
378	}
379	if file.Content != oldContent {
380		// User Manually changed the content store an intermediate version
381		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
382		if err != nil {
383			slog.Debug("Error creating file history version", "error", err)
384		}
385	}
386	// Store the new version
387	_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
388	if err != nil {
389		slog.Debug("Error creating file history version", "error", err)
390	}
391
392	recordFileWrite(filePath)
393	recordFileRead(filePath)
394
395	return WithResponseMetadata(
396		NewTextResponse("Content deleted from file: "+filePath),
397		EditResponseMetadata{
398			OldContent: oldContent,
399			NewContent: newContent,
400			Additions:  additions,
401			Removals:   removals,
402		},
403	), nil
404}
405
406func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
407	fileInfo, err := os.Stat(filePath)
408	if err != nil {
409		if os.IsNotExist(err) {
410			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
411		}
412		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
413	}
414
415	if fileInfo.IsDir() {
416		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
417	}
418
419	if getLastReadTime(filePath).IsZero() {
420		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
421	}
422
423	modTime := fileInfo.ModTime()
424	lastRead := getLastReadTime(filePath)
425	if modTime.After(lastRead) {
426		return NewTextErrorResponse(
427			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
428				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
429			)), nil
430	}
431
432	content, err := os.ReadFile(filePath)
433	if err != nil {
434		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
435	}
436
437	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
438
439	var newContent string
440	var replacementCount int
441
442	if replaceAll {
443		newContent = strings.ReplaceAll(oldContent, oldString, newString)
444		replacementCount = strings.Count(oldContent, oldString)
445		if replacementCount == 0 {
446			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
447		}
448	} else {
449		index := strings.Index(oldContent, oldString)
450		if index == -1 {
451			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
452		}
453
454		lastIndex := strings.LastIndex(oldContent, oldString)
455		if index != lastIndex {
456			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
457		}
458
459		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
460		replacementCount = 1
461	}
462
463	if oldContent == newContent {
464		return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
465	}
466	sessionID, messageID := GetContextValues(ctx)
467
468	if sessionID == "" || messageID == "" {
469		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
470	}
471	_, additions, removals := diff.GenerateDiff(
472		oldContent,
473		newContent,
474		strings.TrimPrefix(filePath, e.workingDir),
475	)
476
477	p := e.permissions.Request(
478		permission.CreatePermissionRequest{
479			SessionID:   sessionID,
480			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
481			ToolCallID:  call.ID,
482			ToolName:    EditToolName,
483			Action:      "write",
484			Description: fmt.Sprintf("Replace content in file %s", filePath),
485			Params: EditPermissionsParams{
486				FilePath:   filePath,
487				OldContent: oldContent,
488				NewContent: newContent,
489			},
490		},
491	)
492	if !p {
493		return ToolResponse{}, permission.ErrorPermissionDenied
494	}
495
496	if isCrlf {
497		newContent, _ = fsext.ToWindowsLineEndings(newContent)
498	}
499
500	err = os.WriteFile(filePath, []byte(newContent), 0o644)
501	if err != nil {
502		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
503	}
504
505	// Check if file exists in history
506	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
507	if err != nil {
508		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
509		if err != nil {
510			// Log error but don't fail the operation
511			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
512		}
513	}
514	if file.Content != oldContent {
515		// User Manually changed the content store an intermediate version
516		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
517		if err != nil {
518			slog.Debug("Error creating file history version", "error", err)
519		}
520	}
521	// Store the new version
522	_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
523	if err != nil {
524		slog.Debug("Error creating file history version", "error", err)
525	}
526
527	recordFileWrite(filePath)
528	recordFileRead(filePath)
529
530	return WithResponseMetadata(
531		NewTextResponse("Content replaced in file: "+filePath),
532		EditResponseMetadata{
533			OldContent: oldContent,
534			NewContent: newContent,
535			Additions:  additions,
536			Removals:   removals,
537		}), nil
538}