multiedit.go

  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/diff"
 15	"github.com/charmbracelet/crush/internal/filepathext"
 16	"github.com/charmbracelet/crush/internal/filetracker"
 17	"github.com/charmbracelet/crush/internal/fsext"
 18	"github.com/charmbracelet/crush/internal/history"
 19	"github.com/charmbracelet/crush/internal/lsp"
 20	"github.com/charmbracelet/crush/internal/permission"
 21)
 22
 23type MultiEditOperation struct {
 24	OldString  string `json:"old_string" description:"The text to replace"`
 25	NewString  string `json:"new_string" description:"The text to replace it with"`
 26	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
 27}
 28
 29type MultiEditParams struct {
 30	FilePath string               `json:"file_path" description:"The absolute path to the file to modify"`
 31	Edits    []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
 32}
 33
 34type MultiEditPermissionsParams struct {
 35	FilePath   string `json:"file_path"`
 36	OldContent string `json:"old_content,omitempty"`
 37	NewContent string `json:"new_content,omitempty"`
 38}
 39
 40type FailedEdit struct {
 41	Index int                `json:"index"`
 42	Error string             `json:"error"`
 43	Edit  MultiEditOperation `json:"edit"`
 44}
 45
 46type MultiEditResponseMetadata struct {
 47	Additions    int          `json:"additions"`
 48	Removals     int          `json:"removals"`
 49	OldContent   string       `json:"old_content,omitempty"`
 50	NewContent   string       `json:"new_content,omitempty"`
 51	EditsApplied int          `json:"edits_applied"`
 52	EditsFailed  []FailedEdit `json:"edits_failed,omitempty"`
 53}
 54
 55const MultiEditToolName = "multiedit"
 56
 57//go:embed multiedit.md
 58var multieditDescription string
 59
 60func NewMultiEditTool(
 61	lspManager *lsp.Manager,
 62	permissions permission.Service,
 63	files history.Service,
 64	filetracker filetracker.Service,
 65	workingDir string,
 66) fantasy.AgentTool {
 67	return fantasy.NewAgentTool(
 68		MultiEditToolName,
 69		multieditDescription,
 70		func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 71			if params.FilePath == "" {
 72				return fantasy.NewTextErrorResponse("file_path is required"), nil
 73			}
 74
 75			if len(params.Edits) == 0 {
 76				return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
 77			}
 78
 79			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
 80
 81			// Validate all edits before applying any
 82			if err := validateEdits(params.Edits); err != nil {
 83				return fantasy.NewTextErrorResponse(err.Error()), nil
 84			}
 85
 86			var response fantasy.ToolResponse
 87			var err error
 88
 89			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 90			// Handle file creation case (first edit has empty old_string)
 91			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
 92				response, err = processMultiEditWithCreation(editCtx, params, call)
 93			} else {
 94				response, err = processMultiEditExistingFile(editCtx, params, call)
 95			}
 96
 97			if err != nil {
 98				return response, err
 99			}
100
101			if response.IsError {
102				return response, nil
103			}
104
105			// Notify LSP clients about the change
106			notifyLSPs(ctx, lspManager, params.FilePath)
107
108			// Wait for LSP diagnostics and add them to the response
109			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
110			text += getDiagnostics(params.FilePath, lspManager)
111			response.Content = text
112			return response, nil
113		},
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		resp := NewPermissionDeniedResponse()
200		resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
201			OldContent:   "",
202			NewContent:   currentContent,
203			Additions:    additions,
204			Removals:     removals,
205			EditsApplied: editsApplied,
206			EditsFailed:  failedEdits,
207		})
208		return resp, nil
209	}
210
211	// Write the file
212	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
213	if err != nil {
214		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
215	}
216
217	// Update file history
218	_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
219	if err != nil {
220		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
221	}
222
223	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
224	if err != nil {
225		slog.Error("Error creating file history version", "error", err)
226	}
227
228	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
229
230	var message string
231	if len(failedEdits) > 0 {
232		message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
233	} else {
234		message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
235	}
236
237	return fantasy.WithResponseMetadata(
238		fantasy.NewTextResponse(message),
239		MultiEditResponseMetadata{
240			OldContent:   "",
241			NewContent:   currentContent,
242			Additions:    additions,
243			Removals:     removals,
244			EditsApplied: editsApplied,
245			EditsFailed:  failedEdits,
246		},
247	), nil
248}
249
250func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
251	// Validate file exists and is readable
252	fileInfo, err := os.Stat(params.FilePath)
253	if err != nil {
254		if os.IsNotExist(err) {
255			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
256		}
257		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
258	}
259
260	if fileInfo.IsDir() {
261		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
262	}
263
264	sessionID := GetSessionFromContext(edit.ctx)
265	if sessionID == "" {
266		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
267	}
268
269	// Check if file was read before editing
270	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
271	if lastRead.IsZero() {
272		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
273	}
274
275	// Check if file was modified since last read.
276	modTime := fileInfo.ModTime().Truncate(time.Second)
277	if modTime.After(lastRead) {
278		return fantasy.NewTextErrorResponse(
279			fmt.Sprintf(
280				"file %s has been modified since it was last read (mod time: %s, last read: %s)",
281				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
282			),
283		), nil
284	}
285
286	// Read current file content
287	content, err := os.ReadFile(params.FilePath)
288	if err != nil {
289		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
290	}
291
292	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
293	currentContent := oldContent
294
295	// Apply all edits sequentially, tracking failures
296	var failedEdits []FailedEdit
297	for i, edit := range params.Edits {
298		newContent, err := applyEditToContent(currentContent, edit)
299		if err != nil {
300			failedEdits = append(failedEdits, FailedEdit{
301				Index: i + 1,
302				Error: err.Error(),
303				Edit:  edit,
304			})
305			continue
306		}
307		currentContent = newContent
308	}
309
310	// Check if content actually changed
311	if oldContent == currentContent {
312		// If we have failed edits, report them
313		if len(failedEdits) > 0 {
314			return fantasy.WithResponseMetadata(
315				fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
316				MultiEditResponseMetadata{
317					EditsApplied: 0,
318					EditsFailed:  failedEdits,
319				},
320			), nil
321		}
322		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
323	}
324
325	// Generate diff and check permissions
326	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
327
328	editsApplied := len(params.Edits) - len(failedEdits)
329	var description string
330	if len(failedEdits) > 0 {
331		description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
332	} else {
333		description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
334	}
335	p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
336		SessionID:   sessionID,
337		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
338		ToolCallID:  call.ID,
339		ToolName:    MultiEditToolName,
340		Action:      "write",
341		Description: description,
342		Params: MultiEditPermissionsParams{
343			FilePath:   params.FilePath,
344			OldContent: oldContent,
345			NewContent: currentContent,
346		},
347	})
348	if err != nil {
349		return fantasy.ToolResponse{}, err
350	}
351	if !p {
352		resp := NewPermissionDeniedResponse()
353		resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
354			OldContent:   oldContent,
355			NewContent:   currentContent,
356			Additions:    additions,
357			Removals:     removals,
358			EditsApplied: editsApplied,
359			EditsFailed:  failedEdits,
360		})
361		return resp, nil
362	}
363
364	if isCrlf {
365		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
366	}
367
368	// Write the updated content
369	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
370	if err != nil {
371		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
372	}
373
374	// Update file history
375	file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
376	if err != nil {
377		_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
378		if err != nil {
379			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
380		}
381	}
382	if file.Content != oldContent {
383		// User manually changed the content, store an intermediate version
384		_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
385		if err != nil {
386			slog.Error("Error creating file history version", "error", err)
387		}
388	}
389
390	// Store the new version
391	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
392	if err != nil {
393		slog.Error("Error creating file history version", "error", err)
394	}
395
396	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
397
398	var message string
399	if len(failedEdits) > 0 {
400		message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
401	} else {
402		message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
403	}
404
405	return fantasy.WithResponseMetadata(
406		fantasy.NewTextResponse(message),
407		MultiEditResponseMetadata{
408			OldContent:   oldContent,
409			NewContent:   currentContent,
410			Additions:    additions,
411			Removals:     removals,
412			EditsApplied: editsApplied,
413			EditsFailed:  failedEdits,
414		},
415	), nil
416}
417
418func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
419	if edit.OldString == "" && edit.NewString == "" {
420		return content, nil
421	}
422
423	if edit.OldString == "" {
424		return "", fmt.Errorf("old_string cannot be empty for content replacement")
425	}
426
427	var newContent string
428	var replacementCount int
429
430	if edit.ReplaceAll {
431		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
432		replacementCount = strings.Count(content, edit.OldString)
433		if replacementCount == 0 {
434			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
435		}
436	} else {
437		index := strings.Index(content, edit.OldString)
438		if index == -1 {
439			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
440		}
441
442		lastIndex := strings.LastIndex(content, edit.OldString)
443		if index != lastIndex {
444			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")
445		}
446
447		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
448		replacementCount = 1
449	}
450
451	return newContent, nil
452}