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