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