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/config"
14 "github.com/charmbracelet/crush/internal/diff"
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}
26
27type EditPermissionsParams struct {
28 FilePath string `json:"file_path"`
29 OldContent string `json:"old_content,omitempty"`
30 NewContent string `json:"new_content,omitempty"`
31}
32
33type EditResponseMetadata struct {
34 Additions int `json:"additions"`
35 Removals int `json:"removals"`
36 OldContent string `json:"old_content,omitempty"`
37 NewContent string `json:"new_content,omitempty"`
38}
39
40type editTool struct {
41 lspClients map[string]*lsp.Client
42 permissions permission.Service
43 files history.Service
44}
45
46const (
47 EditToolName = "edit"
48 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.
49
50Before using this tool:
51
521. Use the FileRead tool to understand the file's contents and context
53
542. Verify the directory path is correct (only applicable when creating new files):
55 - Use the LS tool to verify the parent directory exists and is the correct location
56
57To make a file edit, provide the following:
581. file_path: The absolute path to the file to modify (must be absolute, not relative)
592. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
603. new_string: The edited text to replace the old_string
61
62Special cases:
63- To create a new file: provide file_path and new_string, leave old_string empty
64- To delete content: provide file_path and old_string, leave new_string empty
65
66The tool will replace ONE occurrence of old_string with new_string in the specified file.
67
68CRITICAL REQUIREMENTS FOR USING THIS TOOL:
69
701. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
71 - Include AT LEAST 3-5 lines of context BEFORE the change point
72 - Include AT LEAST 3-5 lines of context AFTER the change point
73 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
74
752. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
76 - Make separate calls to this tool for each instance
77 - Each call must uniquely identify its specific instance using extensive context
78
793. VERIFICATION: Before using this tool:
80 - Check how many instances of the target text exist in the file
81 - If multiple instances exist, gather enough context to uniquely identify each one
82 - Plan separate tool calls for each instance
83
84WARNING: If you do not follow these requirements:
85 - The tool will fail if old_string matches multiple locations
86 - The tool will fail if old_string doesn't match exactly (including whitespace)
87 - You may change the wrong instance if you don't include enough context
88
89When making edits:
90 - Ensure the edit results in idiomatic, correct code
91 - Do not leave the code in a broken state
92 - Always use absolute file paths (starting with /)
93
94WINDOWS NOTES:
95- File paths should use forward slashes (/) for cross-platform compatibility
96- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
97- File permissions are handled automatically by the Go runtime
98
99Remember: 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.`
100)
101
102func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
103 return &editTool{
104 lspClients: lspClients,
105 permissions: permissions,
106 files: files,
107 }
108}
109
110func (e *editTool) Name() string {
111 return EditToolName
112}
113
114func (e *editTool) Info() ToolInfo {
115 return ToolInfo{
116 Name: EditToolName,
117 Description: editDescription,
118 Parameters: map[string]any{
119 "file_path": map[string]any{
120 "type": "string",
121 "description": "The absolute path to the file to modify",
122 },
123 "old_string": map[string]any{
124 "type": "string",
125 "description": "The text to replace",
126 },
127 "new_string": map[string]any{
128 "type": "string",
129 "description": "The text to replace it with",
130 },
131 },
132 Required: []string{"file_path", "old_string", "new_string"},
133 }
134}
135
136func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
137 var params EditParams
138 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
139 return NewTextErrorResponse("invalid parameters"), nil
140 }
141
142 if params.FilePath == "" {
143 return NewTextErrorResponse("file_path is required"), nil
144 }
145
146 if !filepath.IsAbs(params.FilePath) {
147 wd := config.Get().WorkingDir()
148 params.FilePath = filepath.Join(wd, params.FilePath)
149 }
150
151 var response ToolResponse
152 var err error
153
154 if params.OldString == "" {
155 response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
156 if err != nil {
157 return response, err
158 }
159 }
160
161 if params.NewString == "" {
162 response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
163 if err != nil {
164 return response, err
165 }
166 }
167
168 response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
169 if err != nil {
170 return response, err
171 }
172 if response.IsError {
173 // Return early if there was an error during content replacement
174 // This prevents unnecessary LSP diagnostics processing
175 return response, nil
176 }
177
178 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
179 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
180 text += getDiagnostics(params.FilePath, e.lspClients)
181 response.Content = text
182 return response, nil
183}
184
185func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
186 fileInfo, err := os.Stat(filePath)
187 if err == nil {
188 if fileInfo.IsDir() {
189 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
190 }
191 return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
192 } else if !os.IsNotExist(err) {
193 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
194 }
195
196 dir := filepath.Dir(filePath)
197 if err = os.MkdirAll(dir, 0o755); err != nil {
198 return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
199 }
200
201 sessionID, messageID := GetContextValues(ctx)
202 if sessionID == "" || messageID == "" {
203 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
204 }
205
206 _, additions, removals := diff.GenerateDiff(
207 "",
208 content,
209 filePath,
210 )
211 rootDir := config.Get().WorkingDir()
212 permissionPath := filepath.Dir(filePath)
213 if strings.HasPrefix(filePath, rootDir) {
214 permissionPath = rootDir
215 }
216 p := e.permissions.Request(
217 permission.CreatePermissionRequest{
218 SessionID: sessionID,
219 Path: permissionPath,
220 ToolName: EditToolName,
221 Action: "write",
222 Description: fmt.Sprintf("Create file %s", filePath),
223 Params: EditPermissionsParams{
224 FilePath: filePath,
225 OldContent: "",
226 NewContent: content,
227 },
228 },
229 )
230 if !p {
231 return ToolResponse{}, permission.ErrorPermissionDenied
232 }
233
234 err = os.WriteFile(filePath, []byte(content), 0o644)
235 if err != nil {
236 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
237 }
238
239 // File can't be in the history so we create a new file history
240 _, err = e.files.Create(ctx, sessionID, filePath, "")
241 if err != nil {
242 // Log error but don't fail the operation
243 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
244 }
245
246 // Add the new content to the file history
247 _, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
248 if err != nil {
249 // Log error but don't fail the operation
250 slog.Debug("Error creating file history version", "error", err)
251 }
252
253 recordFileWrite(filePath)
254 recordFileRead(filePath)
255
256 return WithResponseMetadata(
257 NewTextResponse("File created: "+filePath),
258 EditResponseMetadata{
259 OldContent: "",
260 NewContent: content,
261 Additions: additions,
262 Removals: removals,
263 },
264 ), nil
265}
266
267func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
268 fileInfo, err := os.Stat(filePath)
269 if err != nil {
270 if os.IsNotExist(err) {
271 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
272 }
273 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
274 }
275
276 if fileInfo.IsDir() {
277 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
278 }
279
280 if getLastReadTime(filePath).IsZero() {
281 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
282 }
283
284 modTime := fileInfo.ModTime()
285 lastRead := getLastReadTime(filePath)
286 if modTime.After(lastRead) {
287 return NewTextErrorResponse(
288 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
289 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
290 )), nil
291 }
292
293 content, err := os.ReadFile(filePath)
294 if err != nil {
295 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
296 }
297
298 oldContent := string(content)
299
300 index := strings.Index(oldContent, oldString)
301 if index == -1 {
302 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
303 }
304
305 lastIndex := strings.LastIndex(oldContent, oldString)
306 if index != lastIndex {
307 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
308 }
309
310 newContent := oldContent[:index] + oldContent[index+len(oldString):]
311
312 sessionID, messageID := GetContextValues(ctx)
313
314 if sessionID == "" || messageID == "" {
315 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
316 }
317
318 _, additions, removals := diff.GenerateDiff(
319 oldContent,
320 newContent,
321 filePath,
322 )
323
324 rootDir := config.Get().WorkingDir()
325 permissionPath := filepath.Dir(filePath)
326 if strings.HasPrefix(filePath, rootDir) {
327 permissionPath = rootDir
328 }
329 p := e.permissions.Request(
330 permission.CreatePermissionRequest{
331 SessionID: sessionID,
332 Path: permissionPath,
333 ToolName: EditToolName,
334 Action: "write",
335 Description: fmt.Sprintf("Delete content from file %s", filePath),
336 Params: EditPermissionsParams{
337 FilePath: filePath,
338 OldContent: oldContent,
339 NewContent: newContent,
340 },
341 },
342 )
343 if !p {
344 return ToolResponse{}, permission.ErrorPermissionDenied
345 }
346
347 err = os.WriteFile(filePath, []byte(newContent), 0o644)
348 if err != nil {
349 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
350 }
351
352 // Check if file exists in history
353 file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
354 if err != nil {
355 _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
356 if err != nil {
357 // Log error but don't fail the operation
358 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
359 }
360 }
361 if file.Content != oldContent {
362 // User Manually changed the content store an intermediate version
363 _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
364 if err != nil {
365 slog.Debug("Error creating file history version", "error", err)
366 }
367 }
368 // Store the new version
369 _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
370 if err != nil {
371 slog.Debug("Error creating file history version", "error", err)
372 }
373
374 recordFileWrite(filePath)
375 recordFileRead(filePath)
376
377 return WithResponseMetadata(
378 NewTextResponse("Content deleted from file: "+filePath),
379 EditResponseMetadata{
380 OldContent: oldContent,
381 NewContent: newContent,
382 Additions: additions,
383 Removals: removals,
384 },
385 ), nil
386}
387
388func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
389 fileInfo, err := os.Stat(filePath)
390 if err != nil {
391 if os.IsNotExist(err) {
392 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
393 }
394 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
395 }
396
397 if fileInfo.IsDir() {
398 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
399 }
400
401 if getLastReadTime(filePath).IsZero() {
402 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
403 }
404
405 modTime := fileInfo.ModTime()
406 lastRead := getLastReadTime(filePath)
407 if modTime.After(lastRead) {
408 return NewTextErrorResponse(
409 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
410 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
411 )), nil
412 }
413
414 content, err := os.ReadFile(filePath)
415 if err != nil {
416 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
417 }
418
419 oldContent := string(content)
420
421 index := strings.Index(oldContent, oldString)
422 if index == -1 {
423 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
424 }
425
426 lastIndex := strings.LastIndex(oldContent, oldString)
427 if index != lastIndex {
428 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
429 }
430
431 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
432
433 if oldContent == newContent {
434 return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
435 }
436 sessionID, messageID := GetContextValues(ctx)
437
438 if sessionID == "" || messageID == "" {
439 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
440 }
441 _, additions, removals := diff.GenerateDiff(
442 oldContent,
443 newContent,
444 filePath,
445 )
446 rootDir := config.Get().WorkingDir()
447 permissionPath := filepath.Dir(filePath)
448 if strings.HasPrefix(filePath, rootDir) {
449 permissionPath = rootDir
450 }
451 p := e.permissions.Request(
452 permission.CreatePermissionRequest{
453 SessionID: sessionID,
454 Path: permissionPath,
455 ToolName: EditToolName,
456 Action: "write",
457 Description: fmt.Sprintf("Replace content in file %s", filePath),
458 Params: EditPermissionsParams{
459 FilePath: filePath,
460 OldContent: oldContent,
461 NewContent: newContent,
462 },
463 },
464 )
465 if !p {
466 return ToolResponse{}, permission.ErrorPermissionDenied
467 }
468
469 err = os.WriteFile(filePath, []byte(newContent), 0o644)
470 if err != nil {
471 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
472 }
473
474 // Check if file exists in history
475 file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
476 if err != nil {
477 _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
478 if err != nil {
479 // Log error but don't fail the operation
480 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
481 }
482 }
483 if file.Content != oldContent {
484 // User Manually changed the content store an intermediate version
485 _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
486 if err != nil {
487 slog.Debug("Error creating file history version", "error", err)
488 }
489 }
490 // Store the new version
491 _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
492 if err != nil {
493 slog.Debug("Error creating file history version", "error", err)
494 }
495
496 recordFileWrite(filePath)
497 recordFileRead(filePath)
498
499 return WithResponseMetadata(
500 NewTextResponse("Content replaced in file: "+filePath),
501 EditResponseMetadata{
502 OldContent: oldContent,
503 NewContent: newContent,
504 Additions: additions,
505 Removals: removals,
506 }), nil
507}