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