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 []byte
 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		string(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
116func validateEdits(edits []MultiEditOperation) error {
117	for i, edit := range edits {
118		// Only the first edit can have empty old_string (for file creation)
119		if i > 0 && edit.OldString == "" {
120			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
121		}
122	}
123	return nil
124}
125
126func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
127	// First edit creates the file
128	firstEdit := params.Edits[0]
129	if firstEdit.OldString != "" {
130		return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
131	}
132
133	// Check if file already exists
134	if _, err := os.Stat(params.FilePath); err == nil {
135		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
136	} else if !os.IsNotExist(err) {
137		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
138	}
139
140	// Create parent directories
141	dir := filepath.Dir(params.FilePath)
142	if err := os.MkdirAll(dir, 0o755); err != nil {
143		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
144	}
145
146	// Start with the content from the first edit
147	currentContent := firstEdit.NewString
148
149	// Apply remaining edits to the content, tracking failures
150	var failedEdits []FailedEdit
151	for i := 1; i < len(params.Edits); i++ {
152		edit := params.Edits[i]
153		newContent, err := applyEditToContent(currentContent, edit)
154		if err != nil {
155			failedEdits = append(failedEdits, FailedEdit{
156				Index: i + 1,
157				Error: err.Error(),
158				Edit:  edit,
159			})
160			continue
161		}
162		currentContent = newContent
163	}
164
165	// Get session and message IDs
166	sessionID := GetSessionFromContext(edit.ctx)
167	if sessionID == "" {
168		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
169	}
170
171	// Check permissions
172	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
173
174	editsApplied := len(params.Edits) - len(failedEdits)
175	var description string
176	if len(failedEdits) > 0 {
177		description = fmt.Sprintf("Create file %s with %d of %d edits (%d failed)", params.FilePath, editsApplied, len(params.Edits), len(failedEdits))
178	} else {
179		description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
180	}
181	p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
182		SessionID:   sessionID,
183		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
184		ToolCallID:  call.ID,
185		ToolName:    MultiEditToolName,
186		Action:      "write",
187		Description: description,
188		Params: MultiEditPermissionsParams{
189			FilePath:   params.FilePath,
190			OldContent: "",
191			NewContent: currentContent,
192		},
193	})
194	if err != nil {
195		return fantasy.ToolResponse{}, err
196	}
197	if !p {
198		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
199	}
200
201	// Write the file
202	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
203	if err != nil {
204		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
205	}
206
207	// Update file history
208	_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
209	if err != nil {
210		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
211	}
212
213	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
214	if err != nil {
215		slog.Error("Error creating file history version", "error", err)
216	}
217
218	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
219
220	var message string
221	if len(failedEdits) > 0 {
222		message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
223	} else {
224		message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
225	}
226
227	return fantasy.WithResponseMetadata(
228		fantasy.NewTextResponse(message),
229		MultiEditResponseMetadata{
230			OldContent:   "",
231			NewContent:   currentContent,
232			Additions:    additions,
233			Removals:     removals,
234			EditsApplied: editsApplied,
235			EditsFailed:  failedEdits,
236		},
237	), nil
238}
239
240func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
241	// Validate file exists and is readable
242	fileInfo, err := os.Stat(params.FilePath)
243	if err != nil {
244		if os.IsNotExist(err) {
245			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
246		}
247		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
248	}
249
250	if fileInfo.IsDir() {
251		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
252	}
253
254	sessionID := GetSessionFromContext(edit.ctx)
255	if sessionID == "" {
256		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
257	}
258
259	// Check if file was read before editing
260	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
261	if lastRead.IsZero() {
262		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
263	}
264
265	// Check if file was modified since last read.
266	modTime := fileInfo.ModTime().Truncate(time.Second)
267	if modTime.After(lastRead) {
268		return fantasy.NewTextErrorResponse(
269			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
270				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
271			)), nil
272	}
273
274	// Read current file content
275	content, err := os.ReadFile(params.FilePath)
276	if err != nil {
277		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
278	}
279
280	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
281	currentContent := oldContent
282
283	// Apply all edits sequentially, tracking failures
284	var failedEdits []FailedEdit
285	for i, edit := range params.Edits {
286		newContent, err := applyEditToContent(currentContent, edit)
287		if err != nil {
288			failedEdits = append(failedEdits, FailedEdit{
289				Index: i + 1,
290				Error: err.Error(),
291				Edit:  edit,
292			})
293			continue
294		}
295		currentContent = newContent
296	}
297
298	// Check if content actually changed
299	if oldContent == currentContent {
300		// If we have failed edits, report them
301		if len(failedEdits) > 0 {
302			return fantasy.WithResponseMetadata(
303				fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
304				MultiEditResponseMetadata{
305					EditsApplied: 0,
306					EditsFailed:  failedEdits,
307				},
308			), nil
309		}
310		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
311	}
312
313	// Generate diff and check permissions
314	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
315
316	editsApplied := len(params.Edits) - len(failedEdits)
317	var description string
318	if len(failedEdits) > 0 {
319		description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
320	} else {
321		description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
322	}
323	p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
324		SessionID:   sessionID,
325		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
326		ToolCallID:  call.ID,
327		ToolName:    MultiEditToolName,
328		Action:      "write",
329		Description: description,
330		Params: MultiEditPermissionsParams{
331			FilePath:   params.FilePath,
332			OldContent: oldContent,
333			NewContent: currentContent,
334		},
335	})
336	if err != nil {
337		return fantasy.ToolResponse{}, err
338	}
339	if !p {
340		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
341	}
342
343	if isCrlf {
344		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
345	}
346
347	// Write the updated content
348	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
349	if err != nil {
350		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
351	}
352
353	// Update file history
354	file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
355	if err != nil {
356		_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
357		if err != nil {
358			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
359		}
360	}
361	if file.Content != oldContent {
362		// User manually changed the content, store an intermediate version
363		_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
364		if err != nil {
365			slog.Error("Error creating file history version", "error", err)
366		}
367	}
368
369	// Store the new version
370	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
371	if err != nil {
372		slog.Error("Error creating file history version", "error", err)
373	}
374
375	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
376
377	var message string
378	if len(failedEdits) > 0 {
379		message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
380	} else {
381		message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
382	}
383
384	return fantasy.WithResponseMetadata(
385		fantasy.NewTextResponse(message),
386		MultiEditResponseMetadata{
387			OldContent:   oldContent,
388			NewContent:   currentContent,
389			Additions:    additions,
390			Removals:     removals,
391			EditsApplied: editsApplied,
392			EditsFailed:  failedEdits,
393		},
394	), nil
395}
396
397func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
398	if edit.OldString == "" && edit.NewString == "" {
399		return content, nil
400	}
401
402	if edit.OldString == "" {
403		return "", fmt.Errorf("old_string cannot be empty for content replacement")
404	}
405
406	var newContent string
407	var replacementCount int
408
409	if edit.ReplaceAll {
410		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
411		replacementCount = strings.Count(content, edit.OldString)
412		if replacementCount == 0 {
413			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
414		}
415	} else {
416		index := strings.Index(content, edit.OldString)
417		if index == -1 {
418			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
419		}
420
421		lastIndex := strings.LastIndex(content, edit.OldString)
422		if index != lastIndex {
423			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")
424		}
425
426		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
427		replacementCount = 1
428	}
429
430	return newContent, nil
431}