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