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