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