edit.go

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