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), ¶ms); 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}