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	return WithResponseMetadata(
256		NewTextResponse("File created: "+filePath),
257		EditResponseMetadata{
258			OldContent: "",
259			NewContent: content,
260			Additions:  additions,
261			Removals:   removals,
262		},
263	), nil
264}
265
266func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
267	fileInfo, err := os.Stat(filePath)
268	if err != nil {
269		if os.IsNotExist(err) {
270			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
271		}
272		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
273	}
274
275	if fileInfo.IsDir() {
276		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
277	}
278
279	if getLastReadTime(filePath).IsZero() {
280		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
281	}
282
283	modTime := fileInfo.ModTime()
284	lastRead := getLastReadTime(filePath)
285	if modTime.After(lastRead) {
286		return NewTextErrorResponse(
287			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
288				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
289			)), nil
290	}
291
292	content, err := os.ReadFile(filePath)
293	if err != nil {
294		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
295	}
296
297	oldContent := string(content)
298
299	index := strings.Index(oldContent, oldString)
300	if index == -1 {
301		return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
302	}
303
304	lastIndex := strings.LastIndex(oldContent, oldString)
305	if index != lastIndex {
306		return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
307	}
308
309	newContent := oldContent[:index] + oldContent[index+len(oldString):]
310
311	sessionID, messageID := GetContextValues(ctx)
312
313	if sessionID == "" || messageID == "" {
314		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
315	}
316
317	_, additions, removals := diff.GenerateDiff(
318		oldContent,
319		newContent,
320		filePath,
321	)
322
323	rootDir := config.WorkingDirectory()
324	permissionPath := filepath.Dir(filePath)
325	if strings.HasPrefix(filePath, rootDir) {
326		permissionPath = rootDir
327	}
328	p := e.permissions.Request(
329		permission.CreatePermissionRequest{
330			SessionID:   sessionID,
331			Path:        permissionPath,
332			ToolName:    EditToolName,
333			Action:      "write",
334			Description: fmt.Sprintf("Delete content from file %s", filePath),
335			Params: EditPermissionsParams{
336				FilePath:   filePath,
337				OldContent: oldContent,
338				NewContent: newContent,
339			},
340		},
341	)
342	if !p {
343		return ToolResponse{}, permission.ErrorPermissionDenied
344	}
345
346	err = os.WriteFile(filePath, []byte(newContent), 0o644)
347	if err != nil {
348		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
349	}
350
351	// Check if file exists in history
352	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
353	if err != nil {
354		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
355		if err != nil {
356			// Log error but don't fail the operation
357			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
358		}
359	}
360	if file.Content != oldContent {
361		// User Manually changed the content store an intermediate version
362		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
363		if err != nil {
364			logging.Debug("Error creating file history version", "error", err)
365		}
366	}
367	// Store the new version
368	_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
369	if err != nil {
370		logging.Debug("Error creating file history version", "error", err)
371	}
372
373	recordFileWrite(filePath)
374	recordFileRead(filePath)
375
376	return WithResponseMetadata(
377		NewTextResponse("Content deleted from file: "+filePath),
378		EditResponseMetadata{
379			OldContent: oldContent,
380			NewContent: newContent,
381			Additions:  additions,
382			Removals:   removals,
383		},
384	), nil
385}
386
387func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
388	fileInfo, err := os.Stat(filePath)
389	if err != nil {
390		if os.IsNotExist(err) {
391			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
392		}
393		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
394	}
395
396	if fileInfo.IsDir() {
397		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
398	}
399
400	if getLastReadTime(filePath).IsZero() {
401		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
402	}
403
404	modTime := fileInfo.ModTime()
405	lastRead := getLastReadTime(filePath)
406	if modTime.After(lastRead) {
407		return NewTextErrorResponse(
408			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
409				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
410			)), nil
411	}
412
413	content, err := os.ReadFile(filePath)
414	if err != nil {
415		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
416	}
417
418	oldContent := string(content)
419
420	index := strings.Index(oldContent, oldString)
421	if index == -1 {
422		return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
423	}
424
425	lastIndex := strings.LastIndex(oldContent, oldString)
426	if index != lastIndex {
427		return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
428	}
429
430	newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
431
432	if oldContent == newContent {
433		return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
434	}
435	sessionID, messageID := GetContextValues(ctx)
436
437	if sessionID == "" || messageID == "" {
438		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
439	}
440	_, additions, removals := diff.GenerateDiff(
441		oldContent,
442		newContent,
443		filePath,
444	)
445	rootDir := config.WorkingDirectory()
446	permissionPath := filepath.Dir(filePath)
447	if strings.HasPrefix(filePath, rootDir) {
448		permissionPath = rootDir
449	}
450	p := e.permissions.Request(
451		permission.CreatePermissionRequest{
452			SessionID:   sessionID,
453			Path:        permissionPath,
454			ToolName:    EditToolName,
455			Action:      "write",
456			Description: fmt.Sprintf("Replace content in file %s", filePath),
457			Params: EditPermissionsParams{
458				FilePath:   filePath,
459				OldContent: oldContent,
460				NewContent: newContent,
461			},
462		},
463	)
464	if !p {
465		return ToolResponse{}, permission.ErrorPermissionDenied
466	}
467
468	err = os.WriteFile(filePath, []byte(newContent), 0o644)
469	if err != nil {
470		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
471	}
472
473	// Check if file exists in history
474	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
475	if err != nil {
476		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
477		if err != nil {
478			// Log error but don't fail the operation
479			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
480		}
481	}
482	if file.Content != oldContent {
483		// User Manually changed the content store an intermediate version
484		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
485		if err != nil {
486			logging.Debug("Error creating file history version", "error", err)
487		}
488	}
489	// Store the new version
490	_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
491	if err != nil {
492		logging.Debug("Error creating file history version", "error", err)
493	}
494
495	recordFileWrite(filePath)
496	recordFileRead(filePath)
497
498	return WithResponseMetadata(
499		NewTextResponse("Content replaced in file: "+filePath),
500		EditResponseMetadata{
501			OldContent: oldContent,
502			NewContent: newContent,
503			Additions:  additions,
504			Removals:   removals,
505		}), nil
506}