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