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