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