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 return WithResponseMetadata(
256 NewTextResponse("File created: "+filePath),
257 EditResponseMetadata{
258 OldContent: "",
259 NewContent: content,
260 Additions: additions,
261 Removals: removals,
262 },
263 ), nil
264}
265
266func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
267 fileInfo, err := os.Stat(filePath)
268 if err != nil {
269 if os.IsNotExist(err) {
270 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
271 }
272 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
273 }
274
275 if fileInfo.IsDir() {
276 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
277 }
278
279 if getLastReadTime(filePath).IsZero() {
280 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
281 }
282
283 modTime := fileInfo.ModTime()
284 lastRead := getLastReadTime(filePath)
285 if modTime.After(lastRead) {
286 return NewTextErrorResponse(
287 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
288 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
289 )), nil
290 }
291
292 content, err := os.ReadFile(filePath)
293 if err != nil {
294 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
295 }
296
297 oldContent := string(content)
298
299 index := strings.Index(oldContent, oldString)
300 if index == -1 {
301 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
302 }
303
304 lastIndex := strings.LastIndex(oldContent, oldString)
305 if index != lastIndex {
306 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
307 }
308
309 newContent := oldContent[:index] + oldContent[index+len(oldString):]
310
311 sessionID, messageID := GetContextValues(ctx)
312
313 if sessionID == "" || messageID == "" {
314 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
315 }
316
317 _, additions, removals := diff.GenerateDiff(
318 oldContent,
319 newContent,
320 filePath,
321 )
322
323 rootDir := config.WorkingDirectory()
324 permissionPath := filepath.Dir(filePath)
325 if strings.HasPrefix(filePath, rootDir) {
326 permissionPath = rootDir
327 }
328 p := e.permissions.Request(
329 permission.CreatePermissionRequest{
330 SessionID: sessionID,
331 Path: permissionPath,
332 ToolName: EditToolName,
333 Action: "write",
334 Description: fmt.Sprintf("Delete content from file %s", filePath),
335 Params: EditPermissionsParams{
336 FilePath: filePath,
337 OldContent: oldContent,
338 NewContent: newContent,
339 },
340 },
341 )
342 if !p {
343 return ToolResponse{}, permission.ErrorPermissionDenied
344 }
345
346 err = os.WriteFile(filePath, []byte(newContent), 0o644)
347 if err != nil {
348 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
349 }
350
351 // Check if file exists in history
352 file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
353 if err != nil {
354 _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
355 if err != nil {
356 // Log error but don't fail the operation
357 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
358 }
359 }
360 if file.Content != oldContent {
361 // User Manually changed the content store an intermediate version
362 _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
363 if err != nil {
364 logging.Debug("Error creating file history version", "error", err)
365 }
366 }
367 // Store the new version
368 _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
369 if err != nil {
370 logging.Debug("Error creating file history version", "error", err)
371 }
372
373 recordFileWrite(filePath)
374 recordFileRead(filePath)
375
376 return WithResponseMetadata(
377 NewTextResponse("Content deleted from file: "+filePath),
378 EditResponseMetadata{
379 OldContent: oldContent,
380 NewContent: newContent,
381 Additions: additions,
382 Removals: removals,
383 },
384 ), nil
385}
386
387func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
388 fileInfo, err := os.Stat(filePath)
389 if err != nil {
390 if os.IsNotExist(err) {
391 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
392 }
393 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
394 }
395
396 if fileInfo.IsDir() {
397 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
398 }
399
400 if getLastReadTime(filePath).IsZero() {
401 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
402 }
403
404 modTime := fileInfo.ModTime()
405 lastRead := getLastReadTime(filePath)
406 if modTime.After(lastRead) {
407 return NewTextErrorResponse(
408 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
409 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
410 )), nil
411 }
412
413 content, err := os.ReadFile(filePath)
414 if err != nil {
415 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
416 }
417
418 oldContent := string(content)
419
420 index := strings.Index(oldContent, oldString)
421 if index == -1 {
422 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
423 }
424
425 lastIndex := strings.LastIndex(oldContent, oldString)
426 if index != lastIndex {
427 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
428 }
429
430 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
431
432 if oldContent == newContent {
433 return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
434 }
435 sessionID, messageID := GetContextValues(ctx)
436
437 if sessionID == "" || messageID == "" {
438 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
439 }
440 _, additions, removals := diff.GenerateDiff(
441 oldContent,
442 newContent,
443 filePath,
444 )
445 rootDir := config.WorkingDirectory()
446 permissionPath := filepath.Dir(filePath)
447 if strings.HasPrefix(filePath, rootDir) {
448 permissionPath = rootDir
449 }
450 p := e.permissions.Request(
451 permission.CreatePermissionRequest{
452 SessionID: sessionID,
453 Path: permissionPath,
454 ToolName: EditToolName,
455 Action: "write",
456 Description: fmt.Sprintf("Replace content in file %s", filePath),
457 Params: EditPermissionsParams{
458 FilePath: filePath,
459 OldContent: oldContent,
460 NewContent: newContent,
461 },
462 },
463 )
464 if !p {
465 return ToolResponse{}, permission.ErrorPermissionDenied
466 }
467
468 err = os.WriteFile(filePath, []byte(newContent), 0o644)
469 if err != nil {
470 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
471 }
472
473 // Check if file exists in history
474 file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
475 if err != nil {
476 _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
477 if err != nil {
478 // Log error but don't fail the operation
479 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
480 }
481 }
482 if file.Content != oldContent {
483 // User Manually changed the content store an intermediate version
484 _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
485 if err != nil {
486 logging.Debug("Error creating file history version", "error", err)
487 }
488 }
489 // Store the new version
490 _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
491 if err != nil {
492 logging.Debug("Error creating file history version", "error", err)
493 }
494
495 recordFileWrite(filePath)
496 recordFileRead(filePath)
497
498 return WithResponseMetadata(
499 NewTextResponse("Content replaced in file: "+filePath),
500 EditResponseMetadata{
501 OldContent: oldContent,
502 NewContent: newContent,
503 Additions: additions,
504 Removals: removals,
505 }), nil
506}