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