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