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) Name() string {
108 return MultiEditToolName
109}
110
111func (m *multiEditTool) Info() ToolInfo {
112 return ToolInfo{
113 Name: MultiEditToolName,
114 Description: multiEditDescription,
115 Parameters: map[string]any{
116 "file_path": map[string]any{
117 "type": "string",
118 "description": "The absolute path to the file to modify",
119 },
120 "edits": map[string]any{
121 "type": "array",
122 "items": map[string]any{
123 "type": "object",
124 "properties": map[string]any{
125 "old_string": map[string]any{
126 "type": "string",
127 "description": "The text to replace",
128 },
129 "new_string": map[string]any{
130 "type": "string",
131 "description": "The text to replace it with",
132 },
133 "replace_all": map[string]any{
134 "type": "boolean",
135 "default": false,
136 "description": "Replace all occurrences of old_string (default false).",
137 },
138 },
139 "required": []string{"old_string", "new_string"},
140 "additionalProperties": false,
141 },
142 "minItems": 1,
143 "description": "Array of edit operations to perform sequentially on the file",
144 },
145 },
146 Required: []string{"file_path", "edits"},
147 }
148}
149
150func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
151 var params MultiEditParams
152 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
153 return NewTextErrorResponse("invalid parameters"), nil
154 }
155
156 if params.FilePath == "" {
157 return NewTextErrorResponse("file_path is required"), nil
158 }
159
160 if len(params.Edits) == 0 {
161 return NewTextErrorResponse("at least one edit operation is required"), nil
162 }
163
164 if !filepath.IsAbs(params.FilePath) {
165 params.FilePath = filepath.Join(m.workingDir, params.FilePath)
166 }
167
168 // Validate all edits before applying any
169 if err := m.validateEdits(params.Edits); err != nil {
170 return NewTextErrorResponse(err.Error()), nil
171 }
172
173 var response ToolResponse
174 var err error
175
176 // Handle file creation case (first edit has empty old_string)
177 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
178 response, err = m.processMultiEditWithCreation(ctx, params, call)
179 } else {
180 response, err = m.processMultiEditExistingFile(ctx, params, call)
181 }
182
183 if err != nil {
184 return response, err
185 }
186
187 if response.IsError {
188 return response, nil
189 }
190
191 // Wait for LSP diagnostics and add them to the response
192 waitForLspDiagnostics(ctx, params.FilePath, m.lspClients)
193 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
194 text += getDiagnostics(params.FilePath, m.lspClients)
195 response.Content = text
196 return response, nil
197}
198
199func (m *multiEditTool) validateEdits(edits []MultiEditOperation) error {
200 for i, edit := range edits {
201 if edit.OldString == edit.NewString {
202 return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
203 }
204 // Only the first edit can have empty old_string (for file creation)
205 if i > 0 && edit.OldString == "" {
206 return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
207 }
208 }
209 return nil
210}
211
212func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
213 // First edit creates the file
214 firstEdit := params.Edits[0]
215 if firstEdit.OldString != "" {
216 return NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
217 }
218
219 // Check if file already exists
220 if _, err := os.Stat(params.FilePath); err == nil {
221 return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
222 } else if !os.IsNotExist(err) {
223 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
224 }
225
226 // Create parent directories
227 dir := filepath.Dir(params.FilePath)
228 if err := os.MkdirAll(dir, 0o755); err != nil {
229 return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
230 }
231
232 // Start with the content from the first edit
233 currentContent := firstEdit.NewString
234
235 // Apply remaining edits to the content
236 for i := 1; i < len(params.Edits); i++ {
237 edit := params.Edits[i]
238 newContent, err := m.applyEditToContent(currentContent, edit)
239 if err != nil {
240 return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
241 }
242 currentContent = newContent
243 }
244
245 // Get session and message IDs
246 sessionID, messageID := GetContextValues(ctx)
247 if sessionID == "" || messageID == "" {
248 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
249 }
250
251 // Check permissions
252 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
253
254 p := m.permissions.Request(permission.CreatePermissionRequest{
255 SessionID: sessionID,
256 Path: fsext.PathOrPrefix(params.FilePath, m.workingDir),
257 ToolCallID: call.ID,
258 ToolName: MultiEditToolName,
259 Action: "write",
260 Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
261 Params: MultiEditPermissionsParams{
262 FilePath: params.FilePath,
263 OldContent: "",
264 NewContent: currentContent,
265 },
266 })
267 if !p {
268 return ToolResponse{}, permission.ErrorPermissionDenied
269 }
270
271 // Write the file
272 err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
273 if err != nil {
274 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
275 }
276
277 // Update file history
278 _, err = m.files.Create(ctx, sessionID, params.FilePath, "")
279 if err != nil {
280 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
281 }
282
283 _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
284 if err != nil {
285 slog.Debug("Error creating file history version", "error", err)
286 }
287
288 recordFileWrite(params.FilePath)
289 recordFileRead(params.FilePath)
290
291 return WithResponseMetadata(
292 NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
293 MultiEditResponseMetadata{
294 OldContent: "",
295 NewContent: currentContent,
296 Additions: additions,
297 Removals: removals,
298 EditsApplied: len(params.Edits),
299 },
300 ), nil
301}
302
303func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
304 // Validate file exists and is readable
305 fileInfo, err := os.Stat(params.FilePath)
306 if err != nil {
307 if os.IsNotExist(err) {
308 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
309 }
310 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
311 }
312
313 if fileInfo.IsDir() {
314 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
315 }
316
317 // Check if file was read before editing
318 if getLastReadTime(params.FilePath).IsZero() {
319 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
320 }
321
322 // Check if file was modified since last read
323 modTime := fileInfo.ModTime()
324 lastRead := getLastReadTime(params.FilePath)
325 if modTime.After(lastRead) {
326 return NewTextErrorResponse(
327 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
328 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
329 )), nil
330 }
331
332 // Read current file content
333 content, err := os.ReadFile(params.FilePath)
334 if err != nil {
335 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
336 }
337
338 oldContent := string(content)
339 currentContent := oldContent
340
341 // Apply all edits sequentially
342 for i, edit := range params.Edits {
343 newContent, err := m.applyEditToContent(currentContent, edit)
344 if err != nil {
345 return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
346 }
347 currentContent = newContent
348 }
349
350 // Check if content actually changed
351 if oldContent == currentContent {
352 return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
353 }
354
355 // Get session and message IDs
356 sessionID, messageID := GetContextValues(ctx)
357 if sessionID == "" || messageID == "" {
358 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
359 }
360
361 // Generate diff and check permissions
362 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
363 p := m.permissions.Request(permission.CreatePermissionRequest{
364 SessionID: sessionID,
365 Path: fsext.PathOrPrefix(params.FilePath, m.workingDir),
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}