1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "charm.land/fantasy"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
19 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/permission"
21)
22
23type MultiEditOperation struct {
24 OldString string `json:"old_string" description:"The text to replace"`
25 NewString string `json:"new_string" description:"The text to replace it with"`
26 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
27}
28
29type MultiEditParams struct {
30 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
31 Edits []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
32}
33
34type MultiEditPermissionsParams struct {
35 FilePath string `json:"file_path"`
36 OldContent string `json:"old_content,omitempty"`
37 NewContent string `json:"new_content,omitempty"`
38}
39
40type MultiEditResponseMetadata struct {
41 Additions int `json:"additions"`
42 Removals int `json:"removals"`
43 OldContent string `json:"old_content,omitempty"`
44 NewContent string `json:"new_content,omitempty"`
45 EditsApplied int `json:"edits_applied"`
46}
47
48const MultiEditToolName = "multiedit"
49
50//go:embed multiedit.md
51var multieditDescription []byte
52
53func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
54 return fantasy.NewAgentTool(
55 MultiEditToolName,
56 string(multieditDescription),
57 func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
58 if params.FilePath == "" {
59 return fantasy.NewTextErrorResponse("file_path is required"), nil
60 }
61
62 if len(params.Edits) == 0 {
63 return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
64 }
65
66 params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
67
68 // Validate all edits before applying any
69 if err := validateEdits(params.Edits); err != nil {
70 return fantasy.NewTextErrorResponse(err.Error()), nil
71 }
72
73 var response fantasy.ToolResponse
74 var err error
75
76 editCtx := editContext{ctx, permissions, files, workingDir}
77 // Handle file creation case (first edit has empty old_string)
78 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
79 response, err = processMultiEditWithCreation(editCtx, params, call)
80 } else {
81 response, err = processMultiEditExistingFile(editCtx, params, call)
82 }
83
84 if err != nil {
85 return response, err
86 }
87
88 if response.IsError {
89 return response, nil
90 }
91
92 // Notify LSP clients about the change
93 notifyLSPs(ctx, lspClients, params.FilePath)
94
95 // Wait for LSP diagnostics and add them to the response
96 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
97 text += getDiagnostics(params.FilePath, lspClients)
98 response.Content = text
99 return response, nil
100 })
101}
102
103func validateEdits(edits []MultiEditOperation) error {
104 for i, edit := range edits {
105 if edit.OldString == edit.NewString {
106 return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
107 }
108 // Only the first edit can have empty old_string (for file creation)
109 if i > 0 && edit.OldString == "" {
110 return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
111 }
112 }
113 return nil
114}
115
116func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
117 // First edit creates the file
118 firstEdit := params.Edits[0]
119 if firstEdit.OldString != "" {
120 return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
121 }
122
123 // Check if file already exists
124 if _, err := os.Stat(params.FilePath); err == nil {
125 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
126 } else if !os.IsNotExist(err) {
127 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
128 }
129
130 // Create parent directories
131 dir := filepath.Dir(params.FilePath)
132 if err := os.MkdirAll(dir, 0o755); err != nil {
133 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
134 }
135
136 // Start with the content from the first edit
137 currentContent := firstEdit.NewString
138
139 // Apply remaining edits to the content
140 for i := 1; i < len(params.Edits); i++ {
141 edit := params.Edits[i]
142 newContent, err := applyEditToContent(currentContent, edit)
143 if err != nil {
144 return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
145 }
146 currentContent = newContent
147 }
148
149 // Get session and message IDs
150 sessionID := GetSessionFromContext(edit.ctx)
151 if sessionID == "" {
152 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
153 }
154
155 // Check permissions
156 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
157
158 p := edit.permissions.Request(permission.CreatePermissionRequest{
159 SessionID: sessionID,
160 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
161 ToolCallID: call.ID,
162 ToolName: MultiEditToolName,
163 Action: "write",
164 Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
165 Params: MultiEditPermissionsParams{
166 FilePath: params.FilePath,
167 OldContent: "",
168 NewContent: currentContent,
169 },
170 })
171 if !p {
172 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
173 }
174
175 // Write the file
176 err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
177 if err != nil {
178 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
179 }
180
181 // Update file history
182 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
183 if err != nil {
184 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
185 }
186
187 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
188 if err != nil {
189 slog.Debug("Error creating file history version", "error", err)
190 }
191
192 recordFileWrite(params.FilePath)
193 recordFileRead(params.FilePath)
194
195 return fantasy.WithResponseMetadata(
196 fantasy.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
197 MultiEditResponseMetadata{
198 OldContent: "",
199 NewContent: currentContent,
200 Additions: additions,
201 Removals: removals,
202 EditsApplied: len(params.Edits),
203 },
204 ), nil
205}
206
207func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
208 // Validate file exists and is readable
209 fileInfo, err := os.Stat(params.FilePath)
210 if err != nil {
211 if os.IsNotExist(err) {
212 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
213 }
214 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
215 }
216
217 if fileInfo.IsDir() {
218 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
219 }
220
221 // Check if file was read before editing
222 if getLastReadTime(params.FilePath).IsZero() {
223 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
224 }
225
226 // Check if file was modified since last read
227 modTime := fileInfo.ModTime()
228 lastRead := getLastReadTime(params.FilePath)
229 if modTime.After(lastRead) {
230 return fantasy.NewTextErrorResponse(
231 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
232 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
233 )), nil
234 }
235
236 // Read current file content
237 content, err := os.ReadFile(params.FilePath)
238 if err != nil {
239 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
240 }
241
242 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
243 currentContent := oldContent
244
245 // Apply all edits sequentially
246 for i, edit := range params.Edits {
247 newContent, err := applyEditToContent(currentContent, edit)
248 if err != nil {
249 return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
250 }
251 currentContent = newContent
252 }
253
254 // Check if content actually changed
255 if oldContent == currentContent {
256 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
257 }
258
259 // Get session and message IDs
260 sessionID := GetSessionFromContext(edit.ctx)
261 if sessionID == "" {
262 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
263 }
264
265 // Generate diff and check permissions
266 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
267 p := edit.permissions.Request(permission.CreatePermissionRequest{
268 SessionID: sessionID,
269 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
270 ToolCallID: call.ID,
271 ToolName: MultiEditToolName,
272 Action: "write",
273 Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
274 Params: MultiEditPermissionsParams{
275 FilePath: params.FilePath,
276 OldContent: oldContent,
277 NewContent: currentContent,
278 },
279 })
280 if !p {
281 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
282 }
283
284 if isCrlf {
285 currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
286 }
287
288 // Write the updated content
289 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
290 if err != nil {
291 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
292 }
293
294 // Update file history
295 file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
296 if err != nil {
297 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
298 if err != nil {
299 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
300 }
301 }
302 if file.Content != oldContent {
303 // User manually changed the content, store an intermediate version
304 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
305 if err != nil {
306 slog.Debug("Error creating file history version", "error", err)
307 }
308 }
309
310 // Store the new version
311 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
312 if err != nil {
313 slog.Debug("Error creating file history version", "error", err)
314 }
315
316 recordFileWrite(params.FilePath)
317 recordFileRead(params.FilePath)
318
319 return fantasy.WithResponseMetadata(
320 fantasy.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
321 MultiEditResponseMetadata{
322 OldContent: oldContent,
323 NewContent: currentContent,
324 Additions: additions,
325 Removals: removals,
326 EditsApplied: len(params.Edits),
327 },
328 ), nil
329}
330
331func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
332 if edit.OldString == "" && edit.NewString == "" {
333 return content, nil
334 }
335
336 if edit.OldString == "" {
337 return "", fmt.Errorf("old_string cannot be empty for content replacement")
338 }
339
340 var newContent string
341 var replacementCount int
342
343 if edit.ReplaceAll {
344 newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
345 replacementCount = strings.Count(content, edit.OldString)
346 if replacementCount == 0 {
347 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
348 }
349 } else {
350 index := strings.Index(content, edit.OldString)
351 if index == -1 {
352 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
353 }
354
355 lastIndex := strings.LastIndex(content, edit.OldString)
356 if index != lastIndex {
357 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")
358 }
359
360 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
361 replacementCount = 1
362 }
363
364 return newContent, nil
365}