edit.go

  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), &params); 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}