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