multiedit.go

  1package tools
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"time"
 11
 12	"github.com/charmbracelet/crush/internal/ai"
 13	"github.com/charmbracelet/crush/internal/diff"
 14	"github.com/charmbracelet/crush/internal/fsext"
 15	"github.com/charmbracelet/crush/internal/history"
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/permission"
 18)
 19
 20type MultiEditOperation struct {
 21	OldString  string `json:"old_string" description:"The text to replace"`
 22	NewString  string `json:"new_string" description:"The text to replace it with"`
 23	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
 24}
 25
 26type MultiEditParams struct {
 27	FilePath string               `json:"file_path" description:"The absolute path to the file to modify"`
 28	Edits    []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
 29}
 30
 31type MultiEditPermissionsParams struct {
 32	FilePath   string `json:"file_path"`
 33	OldContent string `json:"old_content,omitempty"`
 34	NewContent string `json:"new_content,omitempty"`
 35}
 36
 37type MultiEditResponseMetadata struct {
 38	Additions    int    `json:"additions"`
 39	Removals     int    `json:"removals"`
 40	OldContent   string `json:"old_content,omitempty"`
 41	NewContent   string `json:"new_content,omitempty"`
 42	EditsApplied int    `json:"edits_applied"`
 43}
 44
 45const (
 46	MultiEditToolName = "multiedit"
 47)
 48
 49func NewMultiEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) ai.AgentTool {
 50	return ai.NewTypedToolFunc(
 51		MultiEditToolName,
 52		`This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
 53
 54Before using this tool:
 55
 561. Use the Read tool to understand the file's contents and context
 57
 582. Verify the directory path is correct
 59
 60To make multiple file edits, provide the following:
 611. file_path: The absolute path to the file to modify (must be absolute, not relative)
 622. edits: An array of edit operations to perform, where each edit contains:
 63   - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
 64   - new_string: The edited text to replace the old_string
 65   - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
 66
 67IMPORTANT:
 68- All edits are applied in sequence, in the order they are provided
 69- Each edit operates on the result of the previous edit
 70- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
 71- This tool is ideal when you need to make several changes to different parts of the same file
 72
 73CRITICAL REQUIREMENTS:
 741. All edits follow the same requirements as the single Edit tool
 752. The edits are atomic - either all succeed or none are applied
 763. Plan your edits carefully to avoid conflicts between sequential operations
 77
 78WARNING:
 79- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
 80- The tool will fail if edits.old_string and edits.new_string are the same
 81- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
 82
 83When making edits:
 84- Ensure all edits result in idiomatic, correct code
 85- Do not leave the code in a broken state
 86- Always use absolute file paths (starting with /)
 87- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
 88- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
 89
 90If you want to create a new file, use:
 91- A new file path, including dir name if needed
 92- First edit: empty old_string and the new file's contents as new_string
 93- Subsequent edits: normal edit operations on the created content`,
 94		func(ctx context.Context, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) {
 95			if params.FilePath == "" {
 96				return ai.NewTextErrorResponse("file_path is required"), nil
 97			}
 98
 99			if len(params.Edits) == 0 {
100				return ai.NewTextErrorResponse("at least one edit operation is required"), nil
101			}
102
103			if !filepath.IsAbs(params.FilePath) {
104				params.FilePath = filepath.Join(workingDir, params.FilePath)
105			}
106
107			// Validate all edits before applying any
108			if err := validateEdits(params.Edits); err != nil {
109				return ai.NewTextErrorResponse(err.Error()), nil
110			}
111
112			var response ai.ToolResponse
113			var err error
114
115			// Handle file creation case (first edit has empty old_string)
116			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
117				response, err = processMultiEditWithCreation(ctx, params, call, permissions, files, workingDir)
118			} else {
119				response, err = processMultiEditExistingFile(ctx, params, call, permissions, files, workingDir)
120			}
121
122			if err != nil {
123				return response, err
124			}
125
126			if response.IsError {
127				return response, nil
128			}
129
130			// Wait for LSP diagnostics and add them to the response
131			waitForLspDiagnostics(ctx, params.FilePath, lspClients)
132			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
133			text += getDiagnostics(params.FilePath, lspClients)
134			response.Content = text
135			return response, nil
136		})
137}
138
139func validateEdits(edits []MultiEditOperation) error {
140	for i, edit := range edits {
141		if edit.OldString == edit.NewString {
142			return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
143		}
144		// Only the first edit can have empty old_string (for file creation)
145		if i > 0 && edit.OldString == "" {
146			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
147		}
148	}
149	return nil
150}
151
152func processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ai.ToolCall, permissions permission.Service, files history.Service, workingDir string) (ai.ToolResponse, error) {
153	// First edit creates the file
154	firstEdit := params.Edits[0]
155	if firstEdit.OldString != "" {
156		return ai.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
157	}
158
159	// Check if file already exists
160	if _, err := os.Stat(params.FilePath); err == nil {
161		return ai.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
162	} else if !os.IsNotExist(err) {
163		return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
164	}
165
166	// Create parent directories
167	dir := filepath.Dir(params.FilePath)
168	if err := os.MkdirAll(dir, 0o755); err != nil {
169		return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
170	}
171
172	// Start with the content from the first edit
173	currentContent := firstEdit.NewString
174
175	// Apply remaining edits to the content
176	for i := 1; i < len(params.Edits); i++ {
177		edit := params.Edits[i]
178		newContent, err := applyEditToContent(currentContent, edit)
179		if err != nil {
180			return ai.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
181		}
182		currentContent = newContent
183	}
184
185	// Get session and message IDs
186	sessionID, messageID := GetContextValues(ctx)
187	if sessionID == "" || messageID == "" {
188		return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
189	}
190
191	// Check permissions
192	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, workingDir))
193
194	granted := permissions.Request(permission.CreatePermissionRequest{
195		SessionID:   sessionID,
196		Path:        fsext.PathOrPrefix(params.FilePath, workingDir),
197		ToolCallID:  call.ID,
198		ToolName:    MultiEditToolName,
199		Action:      "write",
200		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
201		Params: MultiEditPermissionsParams{
202			FilePath:   params.FilePath,
203			OldContent: "",
204			NewContent: currentContent,
205		},
206	})
207	if !granted {
208		return ai.ToolResponse{}, permission.ErrorPermissionDenied
209	}
210
211	// Write the file
212	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
213	if err != nil {
214		return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
215	}
216
217	// Update file history
218	_, err = files.Create(ctx, sessionID, params.FilePath, "")
219	if err != nil {
220		return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
221	}
222
223	_, err = files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
224	if err != nil {
225		slog.Debug("Error creating file history version", "error", err)
226	}
227
228	recordFileWrite(params.FilePath)
229	recordFileRead(params.FilePath)
230
231	return ai.WithResponseMetadata(
232		ai.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
233		MultiEditResponseMetadata{
234			OldContent:   "",
235			NewContent:   currentContent,
236			Additions:    additions,
237			Removals:     removals,
238			EditsApplied: len(params.Edits),
239		},
240	), nil
241}
242
243func processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ai.ToolCall, permissions permission.Service, files history.Service, workingDir string) (ai.ToolResponse, error) {
244	// Validate file exists and is readable
245	fileInfo, err := os.Stat(params.FilePath)
246	if err != nil {
247		if os.IsNotExist(err) {
248			return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
249		}
250		return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
251	}
252
253	if fileInfo.IsDir() {
254		return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
255	}
256
257	// Check if file was read before editing
258	if getLastReadTime(params.FilePath).IsZero() {
259		return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
260	}
261
262	// Check if file was modified since last read
263	modTime := fileInfo.ModTime()
264	lastRead := getLastReadTime(params.FilePath)
265	if modTime.After(lastRead) {
266		return ai.NewTextErrorResponse(
267			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
268				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
269			)), nil
270	}
271
272	// Read current file content
273	content, err := os.ReadFile(params.FilePath)
274	if err != nil {
275		return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
276	}
277
278	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
279	currentContent := oldContent
280
281	// Apply all edits sequentially
282	for i, edit := range params.Edits {
283		newContent, err := applyEditToContent(currentContent, edit)
284		if err != nil {
285			return ai.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
286		}
287		currentContent = newContent
288	}
289
290	// Check if content actually changed
291	if oldContent == currentContent {
292		return ai.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
293	}
294
295	// Get session and message IDs
296	sessionID, messageID := GetContextValues(ctx)
297	if sessionID == "" || messageID == "" {
298		return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
299	}
300
301	// Generate diff and check permissions
302	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, workingDir))
303	granted := permissions.Request(permission.CreatePermissionRequest{
304		SessionID:   sessionID,
305		Path:        fsext.PathOrPrefix(params.FilePath, workingDir),
306		ToolCallID:  call.ID,
307		ToolName:    MultiEditToolName,
308		Action:      "write",
309		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
310		Params: MultiEditPermissionsParams{
311			FilePath:   params.FilePath,
312			OldContent: oldContent,
313			NewContent: currentContent,
314		},
315	})
316	if !granted {
317		return ai.ToolResponse{}, permission.ErrorPermissionDenied
318	}
319
320	if isCrlf {
321		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
322	}
323
324	// Write the updated content
325	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
326	if err != nil {
327		return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
328	}
329
330	// Update file history
331	file, err := files.GetByPathAndSession(ctx, params.FilePath, sessionID)
332	if err != nil {
333		_, err = files.Create(ctx, sessionID, params.FilePath, oldContent)
334		if err != nil {
335			return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
336		}
337	}
338	if file.Content != oldContent {
339		// User manually changed the content, store an intermediate version
340		_, err = files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
341		if err != nil {
342			slog.Debug("Error creating file history version", "error", err)
343		}
344	}
345
346	// Store the new version
347	_, err = files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
348	if err != nil {
349		slog.Debug("Error creating file history version", "error", err)
350	}
351
352	recordFileWrite(params.FilePath)
353	recordFileRead(params.FilePath)
354
355	return ai.WithResponseMetadata(
356		ai.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
357		MultiEditResponseMetadata{
358			OldContent:   oldContent,
359			NewContent:   currentContent,
360			Additions:    additions,
361			Removals:     removals,
362			EditsApplied: len(params.Edits),
363		},
364	), nil
365}
366
367func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
368	if edit.OldString == "" && edit.NewString == "" {
369		return content, nil
370	}
371
372	if edit.OldString == "" {
373		return "", fmt.Errorf("old_string cannot be empty for content replacement")
374	}
375
376	var newContent string
377	var replacementCount int
378
379	if edit.ReplaceAll {
380		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
381		replacementCount = strings.Count(content, edit.OldString)
382		if replacementCount == 0 {
383			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
384		}
385	} else {
386		index := strings.Index(content, edit.OldString)
387		if index == -1 {
388			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
389		}
390
391		lastIndex := strings.LastIndex(content, edit.OldString)
392		if index != lastIndex {
393			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")
394		}
395
396		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
397		replacementCount = 1
398	}
399
400	return newContent, nil
401}