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/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), &params); 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, nil
145		}
146	}
147
148	if params.NewString == "" {
149		response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
150		if err != nil {
151			return response, nil
152		}
153	}
154
155	response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
156	if err != nil {
157		return response, nil
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}