multiedit.go

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