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