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