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