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