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