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/termai/internal/config"
 13	"github.com/kujtimiihoxha/termai/internal/lsp"
 14	"github.com/kujtimiihoxha/termai/internal/permission"
 15	"github.com/sergi/go-diff/diffmatchpatch"
 16)
 17
 18type editTool struct {
 19	lspClients map[string]*lsp.Client
 20}
 21
 22const (
 23	EditToolName = "edit"
 24)
 25
 26type EditParams struct {
 27	FilePath  string `json:"file_path"`
 28	OldString string `json:"old_string"`
 29	NewString string `json:"new_string"`
 30}
 31
 32type EditPermissionsParams struct {
 33	FilePath  string `json:"file_path"`
 34	OldString string `json:"old_string"`
 35	NewString string `json:"new_string"`
 36	Diff      string `json:"diff"`
 37}
 38
 39func (e *editTool) Info() ToolInfo {
 40	return ToolInfo{
 41		Name:        EditToolName,
 42		Description: editDescription(),
 43		Parameters: map[string]any{
 44			"file_path": map[string]any{
 45				"type":        "string",
 46				"description": "The absolute path to the file to modify",
 47			},
 48			"old_string": map[string]any{
 49				"type":        "string",
 50				"description": "The text to replace",
 51			},
 52			"new_string": map[string]any{
 53				"type":        "string",
 54				"description": "The text to replace it with",
 55			},
 56		},
 57		Required: []string{"file_path", "old_string", "new_string"},
 58	}
 59}
 60
 61// Run implements Tool.
 62func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 63	var params EditParams
 64	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
 65		return NewTextErrorResponse("invalid parameters"), nil
 66	}
 67
 68	if params.FilePath == "" {
 69		return NewTextErrorResponse("file_path is required"), nil
 70	}
 71
 72	if !filepath.IsAbs(params.FilePath) {
 73		wd := config.WorkingDirectory()
 74		params.FilePath = filepath.Join(wd, params.FilePath)
 75	}
 76
 77	if params.OldString == "" {
 78		result, err := createNewFile(params.FilePath, params.NewString)
 79		if err != nil {
 80			return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
 81		}
 82		return NewTextResponse(result), nil
 83	}
 84
 85	if params.NewString == "" {
 86		result, err := deleteContent(params.FilePath, params.OldString)
 87		if err != nil {
 88			return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
 89		}
 90		return NewTextResponse(result), nil
 91	}
 92
 93	result, err := replaceContent(params.FilePath, params.OldString, params.NewString)
 94	if err != nil {
 95		return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
 96	}
 97
 98	// Wait for LSP diagnostics after editing the file
 99	waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
100	result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
101	result += appendDiagnostics(params.FilePath, e.lspClients)
102	return NewTextResponse(result), nil
103}
104
105func createNewFile(filePath, content string) (string, error) {
106	fileInfo, err := os.Stat(filePath)
107	if err == nil {
108		if fileInfo.IsDir() {
109			return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
110		}
111		return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
112	} else if !os.IsNotExist(err) {
113		return "", fmt.Errorf("failed to access file: %w", err)
114	}
115
116	dir := filepath.Dir(filePath)
117	if err = os.MkdirAll(dir, 0o755); err != nil {
118		return "", fmt.Errorf("failed to create parent directories: %w", err)
119	}
120
121	p := permission.Default.Request(
122		permission.CreatePermissionRequest{
123			Path:        filepath.Dir(filePath),
124			ToolName:    EditToolName,
125			Action:      "create",
126			Description: fmt.Sprintf("Create file %s", filePath),
127			Params: EditPermissionsParams{
128				FilePath:  filePath,
129				OldString: "",
130				NewString: content,
131				Diff:      GenerateDiff("", content),
132			},
133		},
134	)
135	if !p {
136		return "", fmt.Errorf("permission denied")
137	}
138
139	err = os.WriteFile(filePath, []byte(content), 0o644)
140	if err != nil {
141		return "", fmt.Errorf("failed to write file: %w", err)
142	}
143
144	recordFileWrite(filePath)
145	recordFileRead(filePath)
146
147	return "File created: " + filePath, nil
148}
149
150func deleteContent(filePath, oldString string) (string, error) {
151	fileInfo, err := os.Stat(filePath)
152	if err != nil {
153		if os.IsNotExist(err) {
154			return "", fmt.Errorf("file not found: %s", filePath)
155		}
156		return "", fmt.Errorf("failed to access file: %w", err)
157	}
158
159	if fileInfo.IsDir() {
160		return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
161	}
162
163	if getLastReadTime(filePath).IsZero() {
164		return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
165	}
166
167	modTime := fileInfo.ModTime()
168	lastRead := getLastReadTime(filePath)
169	if modTime.After(lastRead) {
170		return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
171			filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
172	}
173
174	content, err := os.ReadFile(filePath)
175	if err != nil {
176		return "", fmt.Errorf("failed to read file: %w", err)
177	}
178
179	oldContent := string(content)
180
181	index := strings.Index(oldContent, oldString)
182	if index == -1 {
183		return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
184	}
185
186	lastIndex := strings.LastIndex(oldContent, oldString)
187	if index != lastIndex {
188		return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
189	}
190
191	newContent := oldContent[:index] + oldContent[index+len(oldString):]
192
193	p := permission.Default.Request(
194		permission.CreatePermissionRequest{
195			Path:        filepath.Dir(filePath),
196			ToolName:    EditToolName,
197			Action:      "delete",
198			Description: fmt.Sprintf("Delete content from file %s", filePath),
199			Params: EditPermissionsParams{
200				FilePath:  filePath,
201				OldString: oldString,
202				NewString: "",
203				Diff:      GenerateDiff(oldContent, newContent),
204			},
205		},
206	)
207	if !p {
208		return "", fmt.Errorf("permission denied")
209	}
210
211	err = os.WriteFile(filePath, []byte(newContent), 0o644)
212	if err != nil {
213		return "", fmt.Errorf("failed to write file: %w", err)
214	}
215
216	recordFileWrite(filePath)
217	recordFileRead(filePath)
218
219	return "Content deleted from file: " + filePath, nil
220}
221
222func replaceContent(filePath, oldString, newString string) (string, error) {
223	fileInfo, err := os.Stat(filePath)
224	if err != nil {
225		if os.IsNotExist(err) {
226			return "", fmt.Errorf("file not found: %s", filePath)
227		}
228		return "", fmt.Errorf("failed to access file: %w", err)
229	}
230
231	if fileInfo.IsDir() {
232		return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
233	}
234
235	if getLastReadTime(filePath).IsZero() {
236		return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
237	}
238
239	modTime := fileInfo.ModTime()
240	lastRead := getLastReadTime(filePath)
241	if modTime.After(lastRead) {
242		return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
243			filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
244	}
245
246	content, err := os.ReadFile(filePath)
247	if err != nil {
248		return "", fmt.Errorf("failed to read file: %w", err)
249	}
250
251	oldContent := string(content)
252
253	index := strings.Index(oldContent, oldString)
254	if index == -1 {
255		return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
256	}
257
258	lastIndex := strings.LastIndex(oldContent, oldString)
259	if index != lastIndex {
260		return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
261	}
262
263	newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
264
265	startIndex := max(0, index-3)
266	oldEndIndex := min(len(oldContent), index+len(oldString)+3)
267	newEndIndex := min(len(newContent), index+len(newString)+3)
268
269	diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
270
271	p := permission.Default.Request(
272		permission.CreatePermissionRequest{
273			Path:        filepath.Dir(filePath),
274			ToolName:    EditToolName,
275			Action:      "replace",
276			Description: fmt.Sprintf("Replace content in file %s", filePath),
277			Params: EditPermissionsParams{
278				FilePath:  filePath,
279				OldString: oldString,
280				NewString: newString,
281				Diff:      diff,
282			},
283		},
284	)
285	if !p {
286		return "", fmt.Errorf("permission denied")
287	}
288
289	err = os.WriteFile(filePath, []byte(newContent), 0o644)
290	if err != nil {
291		return "", fmt.Errorf("failed to write file: %w", err)
292	}
293
294	recordFileWrite(filePath)
295	recordFileRead(filePath)
296
297	return "Content replaced in file: " + filePath, nil
298}
299
300func GenerateDiff(oldContent, newContent string) string {
301	dmp := diffmatchpatch.New()
302	fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent)
303	diffs := dmp.DiffMain(fileAdmp, fileBdmp, false)
304	diffs = dmp.DiffCharsToLines(diffs, dmpStrings)
305	diffs = dmp.DiffCleanupSemantic(diffs)
306	buff := strings.Builder{}
307
308	// Add a header to make the diff more readable
309	buff.WriteString("Changes:\n")
310
311	for _, diff := range diffs {
312		text := diff.Text
313
314		switch diff.Type {
315		case diffmatchpatch.DiffInsert:
316			for line := range strings.SplitSeq(text, "\n") {
317				if line == "" {
318					continue
319				}
320				_, _ = buff.WriteString("+ " + line + "\n")
321			}
322		case diffmatchpatch.DiffDelete:
323			for line := range strings.SplitSeq(text, "\n") {
324				if line == "" {
325					continue
326				}
327				_, _ = buff.WriteString("- " + line + "\n")
328			}
329		case diffmatchpatch.DiffEqual:
330			// Only show a small context for unchanged text
331			lines := strings.Split(text, "\n")
332			if len(lines) > 3 {
333				// Show only first and last line of context with a separator
334				if lines[0] != "" {
335					_, _ = buff.WriteString("  " + lines[0] + "\n")
336				}
337				_, _ = buff.WriteString("  ...\n")
338				if lines[len(lines)-1] != "" {
339					_, _ = buff.WriteString("  " + lines[len(lines)-1] + "\n")
340				}
341			} else {
342				// Show all lines for small contexts
343				for _, line := range lines {
344					if line == "" {
345						continue
346					}
347					_, _ = buff.WriteString("  " + line + "\n")
348				}
349			}
350		}
351	}
352	return buff.String()
353}
354
355func editDescription() string {
356	return `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.
357
358Before using this tool:
359
3601. Use the FileRead tool to understand the file's contents and context
361
3622. Verify the directory path is correct (only applicable when creating new files):
363   - Use the LS tool to verify the parent directory exists and is the correct location
364
365To make a file edit, provide the following:
3661. file_path: The absolute path to the file to modify (must be absolute, not relative)
3672. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
3683. new_string: The edited text to replace the old_string
369
370Special cases:
371- To create a new file: provide file_path and new_string, leave old_string empty
372- To delete content: provide file_path and old_string, leave new_string empty
373
374The tool will replace ONE occurrence of old_string with new_string in the specified file.
375
376CRITICAL REQUIREMENTS FOR USING THIS TOOL:
377
3781. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
379   - Include AT LEAST 3-5 lines of context BEFORE the change point
380   - Include AT LEAST 3-5 lines of context AFTER the change point
381   - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
382
3832. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
384   - Make separate calls to this tool for each instance
385   - Each call must uniquely identify its specific instance using extensive context
386
3873. VERIFICATION: Before using this tool:
388   - Check how many instances of the target text exist in the file
389   - If multiple instances exist, gather enough context to uniquely identify each one
390   - Plan separate tool calls for each instance
391
392WARNING: If you do not follow these requirements:
393   - The tool will fail if old_string matches multiple locations
394   - The tool will fail if old_string doesn't match exactly (including whitespace)
395   - You may change the wrong instance if you don't include enough context
396
397When making edits:
398   - Ensure the edit results in idiomatic, correct code
399   - Do not leave the code in a broken state
400   - Always use absolute file paths (starting with /)
401
402Remember: 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.`
403}
404
405func NewEditTool(lspClients map[string]*lsp.Client) BaseTool {
406	return &editTool{
407		lspClients,
408	}
409}