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