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