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
253 p := m.permissions.Request(permission.CreatePermissionRequest{
254 SessionID: sessionID,
255 Path: params.FilePath,
256 ToolCallID: call.ID,
257 ToolName: MultiEditToolName,
258 Action: "write",
259 Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
260 Params: MultiEditPermissionsParams{
261 FilePath: params.FilePath,
262 OldContent: "",
263 NewContent: currentContent,
264 },
265 })
266 if !p {
267 return ToolResponse{}, permission.ErrorPermissionDenied
268 }
269
270 // Write the file
271 err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
272 if err != nil {
273 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
274 }
275
276 // Update file history
277 _, err = m.files.Create(ctx, sessionID, params.FilePath, "")
278 if err != nil {
279 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
280 }
281
282 _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
283 if err != nil {
284 slog.Debug("Error creating file history version", "error", err)
285 }
286
287 recordFileWrite(params.FilePath)
288 recordFileRead(params.FilePath)
289
290 return WithResponseMetadata(
291 NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
292 MultiEditResponseMetadata{
293 OldContent: "",
294 NewContent: currentContent,
295 Additions: additions,
296 Removals: removals,
297 EditsApplied: len(params.Edits),
298 },
299 ), nil
300}
301
302func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
303 // Validate file exists and is readable
304 fileInfo, err := os.Stat(params.FilePath)
305 if err != nil {
306 if os.IsNotExist(err) {
307 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
308 }
309 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
310 }
311
312 if fileInfo.IsDir() {
313 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
314 }
315
316 // Check if file was read before editing
317 if getLastReadTime(params.FilePath).IsZero() {
318 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
319 }
320
321 // Check if file was modified since last read
322 modTime := fileInfo.ModTime()
323 lastRead := getLastReadTime(params.FilePath)
324 if modTime.After(lastRead) {
325 return NewTextErrorResponse(
326 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
327 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
328 )), nil
329 }
330
331 // Read current file content
332 content, err := os.ReadFile(params.FilePath)
333 if err != nil {
334 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
335 }
336
337 oldContent := string(content)
338 currentContent := oldContent
339
340 // Apply all edits sequentially
341 for i, edit := range params.Edits {
342 newContent, err := m.applyEditToContent(currentContent, edit)
343 if err != nil {
344 return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
345 }
346 currentContent = newContent
347 }
348
349 // Check if content actually changed
350 if oldContent == currentContent {
351 return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
352 }
353
354 // Get session and message IDs
355 sessionID, messageID := GetContextValues(ctx)
356 if sessionID == "" || messageID == "" {
357 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
358 }
359
360 // Generate diff and check permissions
361 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
362
363 p := m.permissions.Request(permission.CreatePermissionRequest{
364 SessionID: sessionID,
365 Path: params.FilePath,
366 ToolCallID: call.ID,
367 ToolName: MultiEditToolName,
368 Action: "write",
369 Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
370 Params: MultiEditPermissionsParams{
371 FilePath: params.FilePath,
372 OldContent: oldContent,
373 NewContent: currentContent,
374 },
375 })
376 if !p {
377 return ToolResponse{}, permission.ErrorPermissionDenied
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}