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