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