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}