1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "charm.land/fantasy"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/filetracker"
18 "github.com/charmbracelet/crush/internal/fsext"
19 "github.com/charmbracelet/crush/internal/history"
20 "github.com/charmbracelet/crush/internal/lsp"
21 "github.com/charmbracelet/crush/internal/permission"
22)
23
24type MultiEditOperation struct {
25 OldString string `json:"old_string" description:"The text to replace"`
26 NewString string `json:"new_string" description:"The text to replace it with"`
27 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
28}
29
30type MultiEditParams struct {
31 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
32 Edits []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
33}
34
35type MultiEditPermissionsParams struct {
36 FilePath string `json:"file_path"`
37 OldContent string `json:"old_content,omitempty"`
38 NewContent string `json:"new_content,omitempty"`
39}
40
41type FailedEdit struct {
42 Index int `json:"index"`
43 Error string `json:"error"`
44 Edit MultiEditOperation `json:"edit"`
45}
46
47type MultiEditResponseMetadata struct {
48 Additions int `json:"additions"`
49 Removals int `json:"removals"`
50 OldContent string `json:"old_content,omitempty"`
51 NewContent string `json:"new_content,omitempty"`
52 EditsApplied int `json:"edits_applied"`
53 EditsFailed []FailedEdit `json:"edits_failed,omitempty"`
54}
55
56const MultiEditToolName = "multiedit"
57
58//go:embed multiedit.md
59var multieditDescription []byte
60
61func NewMultiEditTool(
62 lspClients *csync.Map[string, *lsp.Client],
63 permissions permission.Service,
64 files history.Service,
65 filetracker filetracker.Service,
66 workingDir string,
67) fantasy.AgentTool {
68 return fantasy.NewAgentTool(
69 MultiEditToolName,
70 string(multieditDescription),
71 func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
72 if params.FilePath == "" {
73 return fantasy.NewTextErrorResponse("file_path is required"), nil
74 }
75
76 if len(params.Edits) == 0 {
77 return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
78 }
79
80 params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
81
82 // Validate all edits before applying any
83 if err := validateEdits(params.Edits); err != nil {
84 return fantasy.NewTextErrorResponse(err.Error()), nil
85 }
86
87 var response fantasy.ToolResponse
88 var err error
89
90 editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
91 // Handle file creation case (first edit has empty old_string)
92 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
93 response, err = processMultiEditWithCreation(editCtx, params, call)
94 } else {
95 response, err = processMultiEditExistingFile(editCtx, params, call)
96 }
97
98 if err != nil {
99 return response, err
100 }
101
102 if response.IsError {
103 return response, nil
104 }
105
106 // Notify LSP clients about the change
107 notifyLSPs(ctx, lspClients, params.FilePath)
108
109 // Wait for LSP diagnostics and add them to the response
110 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
111 text += getDiagnostics(params.FilePath, lspClients)
112 response.Content = text
113 return response, nil
114 })
115}
116
117func validateEdits(edits []MultiEditOperation) error {
118 for i, edit := range edits {
119 // Only the first edit can have empty old_string (for file creation)
120 if i > 0 && edit.OldString == "" {
121 return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
122 }
123 }
124 return nil
125}
126
127func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
128 // First edit creates the file
129 firstEdit := params.Edits[0]
130 if firstEdit.OldString != "" {
131 return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
132 }
133
134 // Check if file already exists
135 if _, err := os.Stat(params.FilePath); err == nil {
136 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
137 } else if !os.IsNotExist(err) {
138 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
139 }
140
141 // Create parent directories
142 dir := filepath.Dir(params.FilePath)
143 if err := os.MkdirAll(dir, 0o755); err != nil {
144 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
145 }
146
147 // Start with the content from the first edit
148 currentContent := firstEdit.NewString
149
150 // Apply remaining edits to the content, tracking failures
151 var failedEdits []FailedEdit
152 for i := 1; i < len(params.Edits); i++ {
153 edit := params.Edits[i]
154 newContent, err := applyEditToContent(currentContent, edit)
155 if err != nil {
156 failedEdits = append(failedEdits, FailedEdit{
157 Index: i + 1,
158 Error: err.Error(),
159 Edit: edit,
160 })
161 continue
162 }
163 currentContent = newContent
164 }
165
166 // Get session and message IDs
167 sessionID := GetSessionFromContext(edit.ctx)
168 if sessionID == "" {
169 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
170 }
171
172 // Check permissions
173 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
174
175 editsApplied := len(params.Edits) - len(failedEdits)
176 var description string
177 if len(failedEdits) > 0 {
178 description = fmt.Sprintf("Create file %s with %d of %d edits (%d failed)", params.FilePath, editsApplied, len(params.Edits), len(failedEdits))
179 } else {
180 description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
181 }
182 p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
183 SessionID: sessionID,
184 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
185 ToolCallID: call.ID,
186 ToolName: MultiEditToolName,
187 Action: "write",
188 Description: description,
189 Params: MultiEditPermissionsParams{
190 FilePath: params.FilePath,
191 OldContent: "",
192 NewContent: currentContent,
193 },
194 })
195 if err != nil {
196 return fantasy.ToolResponse{}, err
197 }
198 if !p {
199 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
200 }
201
202 // Write the file
203 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
204 if err != nil {
205 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
206 }
207
208 // Update file history
209 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
210 if err != nil {
211 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
212 }
213
214 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
215 if err != nil {
216 slog.Error("Error creating file history version", "error", err)
217 }
218
219 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
220
221 var message string
222 if len(failedEdits) > 0 {
223 message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
224 } else {
225 message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
226 }
227
228 return fantasy.WithResponseMetadata(
229 fantasy.NewTextResponse(message),
230 MultiEditResponseMetadata{
231 OldContent: "",
232 NewContent: currentContent,
233 Additions: additions,
234 Removals: removals,
235 EditsApplied: editsApplied,
236 EditsFailed: failedEdits,
237 },
238 ), nil
239}
240
241func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
242 // Validate file exists and is readable
243 fileInfo, err := os.Stat(params.FilePath)
244 if err != nil {
245 if os.IsNotExist(err) {
246 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
247 }
248 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
249 }
250
251 if fileInfo.IsDir() {
252 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
253 }
254
255 sessionID := GetSessionFromContext(edit.ctx)
256 if sessionID == "" {
257 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
258 }
259
260 // Check if file was read before editing
261 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
262 if lastRead.IsZero() {
263 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
264 }
265
266 // Check if file was modified since last read.
267 modTime := fileInfo.ModTime().Truncate(time.Second)
268 if modTime.After(lastRead) {
269 return fantasy.NewTextErrorResponse(
270 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
271 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
272 )), nil
273 }
274
275 // Read current file content
276 content, err := os.ReadFile(params.FilePath)
277 if err != nil {
278 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
279 }
280
281 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
282 currentContent := oldContent
283
284 // Apply all edits sequentially, tracking failures
285 var failedEdits []FailedEdit
286 for i, edit := range params.Edits {
287 newContent, err := applyEditToContent(currentContent, edit)
288 if err != nil {
289 failedEdits = append(failedEdits, FailedEdit{
290 Index: i + 1,
291 Error: err.Error(),
292 Edit: edit,
293 })
294 continue
295 }
296 currentContent = newContent
297 }
298
299 // Check if content actually changed
300 if oldContent == currentContent {
301 // If we have failed edits, report them
302 if len(failedEdits) > 0 {
303 return fantasy.WithResponseMetadata(
304 fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
305 MultiEditResponseMetadata{
306 EditsApplied: 0,
307 EditsFailed: failedEdits,
308 },
309 ), nil
310 }
311 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
312 }
313
314 // Generate diff and check permissions
315 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
316
317 editsApplied := len(params.Edits) - len(failedEdits)
318 var description string
319 if len(failedEdits) > 0 {
320 description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
321 } else {
322 description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
323 }
324 p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
325 SessionID: sessionID,
326 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
327 ToolCallID: call.ID,
328 ToolName: MultiEditToolName,
329 Action: "write",
330 Description: description,
331 Params: MultiEditPermissionsParams{
332 FilePath: params.FilePath,
333 OldContent: oldContent,
334 NewContent: currentContent,
335 },
336 })
337 if err != nil {
338 return fantasy.ToolResponse{}, err
339 }
340 if !p {
341 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
342 }
343
344 if isCrlf {
345 currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
346 }
347
348 // Write the updated content
349 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
350 if err != nil {
351 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
352 }
353
354 // Update file history
355 file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
356 if err != nil {
357 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
358 if err != nil {
359 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
360 }
361 }
362 if file.Content != oldContent {
363 // User manually changed the content, store an intermediate version
364 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
365 if err != nil {
366 slog.Error("Error creating file history version", "error", err)
367 }
368 }
369
370 // Store the new version
371 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
372 if err != nil {
373 slog.Error("Error creating file history version", "error", err)
374 }
375
376 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
377
378 var message string
379 if len(failedEdits) > 0 {
380 message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
381 } else {
382 message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
383 }
384
385 return fantasy.WithResponseMetadata(
386 fantasy.NewTextResponse(message),
387 MultiEditResponseMetadata{
388 OldContent: oldContent,
389 NewContent: currentContent,
390 Additions: additions,
391 Removals: removals,
392 EditsApplied: editsApplied,
393 EditsFailed: failedEdits,
394 },
395 ), nil
396}
397
398func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
399 if edit.OldString == "" && edit.NewString == "" {
400 return content, nil
401 }
402
403 if edit.OldString == "" {
404 return "", fmt.Errorf("old_string cannot be empty for content replacement")
405 }
406
407 var newContent string
408 var replacementCount int
409
410 if edit.ReplaceAll {
411 newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
412 replacementCount = strings.Count(content, edit.OldString)
413 if replacementCount == 0 {
414 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
415 }
416 } else {
417 index := strings.Index(content, edit.OldString)
418 if index == -1 {
419 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
420 }
421
422 lastIndex := strings.LastIndex(content, edit.OldString)
423 if index != lastIndex {
424 return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
425 }
426
427 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
428 replacementCount = 1
429 }
430
431 return newContent, nil
432}