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