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