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