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