multiedit.go 15 KB

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