@@ -0,0 +1,467 @@
1+package tools
2+
3+import (
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+
19+type MultiEditOperation struct {
20+ OldString string `json:"old_string"`
21+ NewString string `json:"new_string"`
22+ ReplaceAll bool `json:"replace_all,omitempty"`
23+}
24+
25+type MultiEditParams struct {
26+ FilePath string `json:"file_path"`
27+ Edits []MultiEditOperation `json:"edits"`
28+}
29+
30+type MultiEditPermissionsParams struct {
31+ FilePath string `json:"file_path"`
32+ OldContent string `json:"old_content,omitempty"`
33+ NewContent string `json:"new_content,omitempty"`
34+}
35+
36+type 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+
44+type multiEditTool struct {
45+ lspClients map[string]*lsp.Client
46+ permissions permission.Service
47+ files history.Service
48+ workingDir string
49+}
50+
51+const (
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+
55+Before using this tool:
56+
57+1. Use the Read tool to understand the file's contents and context
58+
59+2. Verify the directory path is correct
60+
61+To make multiple file edits, provide the following:
62+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
63+2. 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+
68+IMPORTANT:
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+
74+CRITICAL REQUIREMENTS:
75+1. All edits follow the same requirements as the single Edit tool
76+2. The edits are atomic - either all succeed or none are applied
77+3. Plan your edits carefully to avoid conflicts between sequential operations
78+
79+WARNING:
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+
84+When 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+
91+If 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+
97+func 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+
106+func (m *multiEditTool) Name() string {
107+ return MultiEditToolName
108+}
109+
110+func (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+
149+func (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+
198+func (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+
211+func (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+
307+func (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+
433+func (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+}