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