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