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