edit.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. // the approaches in this edit tool are sourced from
  2. // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
  3. // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
  4. // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
  5. import z from "zod"
  6. import * as path from "path"
  7. import { Tool } from "./tool"
  8. import { LSP } from "../lsp"
  9. import { createTwoFilesPatch, diffLines } from "diff"
  10. import { Permission } from "../permission"
  11. import DESCRIPTION from "./edit.txt"
  12. import { File } from "../file"
  13. import { Bus } from "../bus"
  14. import { FileTime } from "../file/time"
  15. import { Filesystem } from "../util/filesystem"
  16. import { Instance } from "../project/instance"
  17. import { Agent } from "../agent/agent"
  18. import { Snapshot } from "@/snapshot"
  19. function normalizeLineEndings(text: string): string {
  20. return text.replaceAll("\r\n", "\n")
  21. }
  22. export const EditTool = Tool.define("edit", {
  23. description: DESCRIPTION,
  24. parameters: z.object({
  25. filePath: z.string().describe("The absolute path to the file to modify"),
  26. oldString: z.string().describe("The text to replace"),
  27. newString: z.string().describe("The text to replace it with (must be different from oldString)"),
  28. replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
  29. }),
  30. async execute(params, ctx) {
  31. if (!params.filePath) {
  32. throw new Error("filePath is required")
  33. }
  34. if (params.oldString === params.newString) {
  35. throw new Error("oldString and newString must be different")
  36. }
  37. const agent = await Agent.get(ctx.agent)
  38. const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
  39. if (!Filesystem.contains(Instance.directory, filePath)) {
  40. const parentDir = path.dirname(filePath)
  41. if (agent.permission.external_directory === "ask") {
  42. await Permission.ask({
  43. type: "external_directory",
  44. pattern: [parentDir, path.join(parentDir, "*")],
  45. sessionID: ctx.sessionID,
  46. messageID: ctx.messageID,
  47. callID: ctx.callID,
  48. title: `Edit file outside working directory: ${filePath}`,
  49. metadata: {
  50. filepath: filePath,
  51. parentDir,
  52. },
  53. })
  54. } else if (agent.permission.external_directory === "deny") {
  55. throw new Permission.RejectedError(
  56. ctx.sessionID,
  57. "external_directory",
  58. ctx.callID,
  59. {
  60. filepath: filePath,
  61. parentDir,
  62. },
  63. `File ${filePath} is not in the current working directory`,
  64. )
  65. }
  66. }
  67. let diff = ""
  68. let contentOld = ""
  69. let contentNew = ""
  70. await (async () => {
  71. if (params.oldString === "") {
  72. contentNew = params.newString
  73. diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
  74. if (agent.permission.edit === "ask") {
  75. await Permission.ask({
  76. type: "edit",
  77. sessionID: ctx.sessionID,
  78. messageID: ctx.messageID,
  79. callID: ctx.callID,
  80. title: "Edit this file: " + filePath,
  81. metadata: {
  82. filePath,
  83. diff,
  84. },
  85. })
  86. }
  87. await Bun.write(filePath, params.newString)
  88. await Bus.publish(File.Event.Edited, {
  89. file: filePath,
  90. })
  91. return
  92. }
  93. const file = Bun.file(filePath)
  94. const stats = await file.stat().catch(() => {})
  95. if (!stats) throw new Error(`File ${filePath} not found`)
  96. if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
  97. await FileTime.assert(ctx.sessionID, filePath)
  98. contentOld = await file.text()
  99. contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
  100. diff = trimDiff(
  101. createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
  102. )
  103. if (agent.permission.edit === "ask") {
  104. await Permission.ask({
  105. type: "edit",
  106. sessionID: ctx.sessionID,
  107. messageID: ctx.messageID,
  108. callID: ctx.callID,
  109. title: "Edit this file: " + filePath,
  110. metadata: {
  111. filePath,
  112. diff,
  113. },
  114. })
  115. }
  116. await file.write(contentNew)
  117. await Bus.publish(File.Event.Edited, {
  118. file: filePath,
  119. })
  120. contentNew = await file.text()
  121. diff = trimDiff(
  122. createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
  123. )
  124. })()
  125. FileTime.read(ctx.sessionID, filePath)
  126. let output = ""
  127. await LSP.touchFile(filePath, true)
  128. const diagnostics = await LSP.diagnostics()
  129. for (const [file, issues] of Object.entries(diagnostics)) {
  130. if (issues.length === 0) continue
  131. if (file === filePath) {
  132. output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues
  133. .filter((item) => item.severity === 1)
  134. .map(LSP.Diagnostic.pretty)
  135. .join("\n")}\n</file_diagnostics>\n`
  136. continue
  137. }
  138. }
  139. const filediff: Snapshot.FileDiff = {
  140. file: filePath,
  141. before: contentOld,
  142. after: contentNew,
  143. additions: 0,
  144. deletions: 0,
  145. }
  146. for (const change of diffLines(contentOld, contentNew)) {
  147. if (change.added) filediff.additions += change.count || 0
  148. if (change.removed) filediff.deletions += change.count || 0
  149. }
  150. return {
  151. metadata: {
  152. diagnostics,
  153. diff,
  154. filediff,
  155. },
  156. title: `${path.relative(Instance.worktree, filePath)}`,
  157. output,
  158. }
  159. },
  160. })
  161. export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
  162. // Similarity thresholds for block anchor fallback matching
  163. const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
  164. const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
  165. /**
  166. * Levenshtein distance algorithm implementation
  167. */
  168. function levenshtein(a: string, b: string): number {
  169. // Handle empty strings
  170. if (a === "" || b === "") {
  171. return Math.max(a.length, b.length)
  172. }
  173. const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
  174. Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
  175. )
  176. for (let i = 1; i <= a.length; i++) {
  177. for (let j = 1; j <= b.length; j++) {
  178. const cost = a[i - 1] === b[j - 1] ? 0 : 1
  179. matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
  180. }
  181. }
  182. return matrix[a.length][b.length]
  183. }
  184. export const SimpleReplacer: Replacer = function* (_content, find) {
  185. yield find
  186. }
  187. export const LineTrimmedReplacer: Replacer = function* (content, find) {
  188. const originalLines = content.split("\n")
  189. const searchLines = find.split("\n")
  190. if (searchLines[searchLines.length - 1] === "") {
  191. searchLines.pop()
  192. }
  193. for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
  194. let matches = true
  195. for (let j = 0; j < searchLines.length; j++) {
  196. const originalTrimmed = originalLines[i + j].trim()
  197. const searchTrimmed = searchLines[j].trim()
  198. if (originalTrimmed !== searchTrimmed) {
  199. matches = false
  200. break
  201. }
  202. }
  203. if (matches) {
  204. let matchStartIndex = 0
  205. for (let k = 0; k < i; k++) {
  206. matchStartIndex += originalLines[k].length + 1
  207. }
  208. let matchEndIndex = matchStartIndex
  209. for (let k = 0; k < searchLines.length; k++) {
  210. matchEndIndex += originalLines[i + k].length
  211. if (k < searchLines.length - 1) {
  212. matchEndIndex += 1 // Add newline character except for the last line
  213. }
  214. }
  215. yield content.substring(matchStartIndex, matchEndIndex)
  216. }
  217. }
  218. }
  219. export const BlockAnchorReplacer: Replacer = function* (content, find) {
  220. const originalLines = content.split("\n")
  221. const searchLines = find.split("\n")
  222. if (searchLines.length < 3) {
  223. return
  224. }
  225. if (searchLines[searchLines.length - 1] === "") {
  226. searchLines.pop()
  227. }
  228. const firstLineSearch = searchLines[0].trim()
  229. const lastLineSearch = searchLines[searchLines.length - 1].trim()
  230. const searchBlockSize = searchLines.length
  231. // Collect all candidate positions where both anchors match
  232. const candidates: Array<{ startLine: number; endLine: number }> = []
  233. for (let i = 0; i < originalLines.length; i++) {
  234. if (originalLines[i].trim() !== firstLineSearch) {
  235. continue
  236. }
  237. // Look for the matching last line after this first line
  238. for (let j = i + 2; j < originalLines.length; j++) {
  239. if (originalLines[j].trim() === lastLineSearch) {
  240. candidates.push({ startLine: i, endLine: j })
  241. break // Only match the first occurrence of the last line
  242. }
  243. }
  244. }
  245. // Return immediately if no candidates
  246. if (candidates.length === 0) {
  247. return
  248. }
  249. // Handle single candidate scenario (using relaxed threshold)
  250. if (candidates.length === 1) {
  251. const { startLine, endLine } = candidates[0]
  252. const actualBlockSize = endLine - startLine + 1
  253. let similarity = 0
  254. let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
  255. if (linesToCheck > 0) {
  256. for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
  257. const originalLine = originalLines[startLine + j].trim()
  258. const searchLine = searchLines[j].trim()
  259. const maxLen = Math.max(originalLine.length, searchLine.length)
  260. if (maxLen === 0) {
  261. continue
  262. }
  263. const distance = levenshtein(originalLine, searchLine)
  264. similarity += (1 - distance / maxLen) / linesToCheck
  265. // Exit early when threshold is reached
  266. if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
  267. break
  268. }
  269. }
  270. } else {
  271. // No middle lines to compare, just accept based on anchors
  272. similarity = 1.0
  273. }
  274. if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
  275. let matchStartIndex = 0
  276. for (let k = 0; k < startLine; k++) {
  277. matchStartIndex += originalLines[k].length + 1
  278. }
  279. let matchEndIndex = matchStartIndex
  280. for (let k = startLine; k <= endLine; k++) {
  281. matchEndIndex += originalLines[k].length
  282. if (k < endLine) {
  283. matchEndIndex += 1 // Add newline character except for the last line
  284. }
  285. }
  286. yield content.substring(matchStartIndex, matchEndIndex)
  287. }
  288. return
  289. }
  290. // Calculate similarity for multiple candidates
  291. let bestMatch: { startLine: number; endLine: number } | null = null
  292. let maxSimilarity = -1
  293. for (const candidate of candidates) {
  294. const { startLine, endLine } = candidate
  295. const actualBlockSize = endLine - startLine + 1
  296. let similarity = 0
  297. let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
  298. if (linesToCheck > 0) {
  299. for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
  300. const originalLine = originalLines[startLine + j].trim()
  301. const searchLine = searchLines[j].trim()
  302. const maxLen = Math.max(originalLine.length, searchLine.length)
  303. if (maxLen === 0) {
  304. continue
  305. }
  306. const distance = levenshtein(originalLine, searchLine)
  307. similarity += 1 - distance / maxLen
  308. }
  309. similarity /= linesToCheck // Average similarity
  310. } else {
  311. // No middle lines to compare, just accept based on anchors
  312. similarity = 1.0
  313. }
  314. if (similarity > maxSimilarity) {
  315. maxSimilarity = similarity
  316. bestMatch = candidate
  317. }
  318. }
  319. // Threshold judgment
  320. if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
  321. const { startLine, endLine } = bestMatch
  322. let matchStartIndex = 0
  323. for (let k = 0; k < startLine; k++) {
  324. matchStartIndex += originalLines[k].length + 1
  325. }
  326. let matchEndIndex = matchStartIndex
  327. for (let k = startLine; k <= endLine; k++) {
  328. matchEndIndex += originalLines[k].length
  329. if (k < endLine) {
  330. matchEndIndex += 1
  331. }
  332. }
  333. yield content.substring(matchStartIndex, matchEndIndex)
  334. }
  335. }
  336. export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
  337. const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
  338. const normalizedFind = normalizeWhitespace(find)
  339. // Handle single line matches
  340. const lines = content.split("\n")
  341. for (let i = 0; i < lines.length; i++) {
  342. const line = lines[i]
  343. if (normalizeWhitespace(line) === normalizedFind) {
  344. yield line
  345. } else {
  346. // Only check for substring matches if the full line doesn't match
  347. const normalizedLine = normalizeWhitespace(line)
  348. if (normalizedLine.includes(normalizedFind)) {
  349. // Find the actual substring in the original line that matches
  350. const words = find.trim().split(/\s+/)
  351. if (words.length > 0) {
  352. const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
  353. try {
  354. const regex = new RegExp(pattern)
  355. const match = line.match(regex)
  356. if (match) {
  357. yield match[0]
  358. }
  359. } catch (e) {
  360. // Invalid regex pattern, skip
  361. }
  362. }
  363. }
  364. }
  365. }
  366. // Handle multi-line matches
  367. const findLines = find.split("\n")
  368. if (findLines.length > 1) {
  369. for (let i = 0; i <= lines.length - findLines.length; i++) {
  370. const block = lines.slice(i, i + findLines.length)
  371. if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
  372. yield block.join("\n")
  373. }
  374. }
  375. }
  376. }
  377. export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
  378. const removeIndentation = (text: string) => {
  379. const lines = text.split("\n")
  380. const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
  381. if (nonEmptyLines.length === 0) return text
  382. const minIndent = Math.min(
  383. ...nonEmptyLines.map((line) => {
  384. const match = line.match(/^(\s*)/)
  385. return match ? match[1].length : 0
  386. }),
  387. )
  388. return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
  389. }
  390. const normalizedFind = removeIndentation(find)
  391. const contentLines = content.split("\n")
  392. const findLines = find.split("\n")
  393. for (let i = 0; i <= contentLines.length - findLines.length; i++) {
  394. const block = contentLines.slice(i, i + findLines.length).join("\n")
  395. if (removeIndentation(block) === normalizedFind) {
  396. yield block
  397. }
  398. }
  399. }
  400. export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
  401. const unescapeString = (str: string): string => {
  402. return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
  403. switch (capturedChar) {
  404. case "n":
  405. return "\n"
  406. case "t":
  407. return "\t"
  408. case "r":
  409. return "\r"
  410. case "'":
  411. return "'"
  412. case '"':
  413. return '"'
  414. case "`":
  415. return "`"
  416. case "\\":
  417. return "\\"
  418. case "\n":
  419. return "\n"
  420. case "$":
  421. return "$"
  422. default:
  423. return match
  424. }
  425. })
  426. }
  427. const unescapedFind = unescapeString(find)
  428. // Try direct match with unescaped find string
  429. if (content.includes(unescapedFind)) {
  430. yield unescapedFind
  431. }
  432. // Also try finding escaped versions in content that match unescaped find
  433. const lines = content.split("\n")
  434. const findLines = unescapedFind.split("\n")
  435. for (let i = 0; i <= lines.length - findLines.length; i++) {
  436. const block = lines.slice(i, i + findLines.length).join("\n")
  437. const unescapedBlock = unescapeString(block)
  438. if (unescapedBlock === unescapedFind) {
  439. yield block
  440. }
  441. }
  442. }
  443. export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
  444. // This replacer yields all exact matches, allowing the replace function
  445. // to handle multiple occurrences based on replaceAll parameter
  446. let startIndex = 0
  447. while (true) {
  448. const index = content.indexOf(find, startIndex)
  449. if (index === -1) break
  450. yield find
  451. startIndex = index + find.length
  452. }
  453. }
  454. export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
  455. const trimmedFind = find.trim()
  456. if (trimmedFind === find) {
  457. // Already trimmed, no point in trying
  458. return
  459. }
  460. // Try to find the trimmed version
  461. if (content.includes(trimmedFind)) {
  462. yield trimmedFind
  463. }
  464. // Also try finding blocks where trimmed content matches
  465. const lines = content.split("\n")
  466. const findLines = find.split("\n")
  467. for (let i = 0; i <= lines.length - findLines.length; i++) {
  468. const block = lines.slice(i, i + findLines.length).join("\n")
  469. if (block.trim() === trimmedFind) {
  470. yield block
  471. }
  472. }
  473. }
  474. export const ContextAwareReplacer: Replacer = function* (content, find) {
  475. const findLines = find.split("\n")
  476. if (findLines.length < 3) {
  477. // Need at least 3 lines to have meaningful context
  478. return
  479. }
  480. // Remove trailing empty line if present
  481. if (findLines[findLines.length - 1] === "") {
  482. findLines.pop()
  483. }
  484. const contentLines = content.split("\n")
  485. // Extract first and last lines as context anchors
  486. const firstLine = findLines[0].trim()
  487. const lastLine = findLines[findLines.length - 1].trim()
  488. // Find blocks that start and end with the context anchors
  489. for (let i = 0; i < contentLines.length; i++) {
  490. if (contentLines[i].trim() !== firstLine) continue
  491. // Look for the matching last line
  492. for (let j = i + 2; j < contentLines.length; j++) {
  493. if (contentLines[j].trim() === lastLine) {
  494. // Found a potential context block
  495. const blockLines = contentLines.slice(i, j + 1)
  496. const block = blockLines.join("\n")
  497. // Check if the middle content has reasonable similarity
  498. // (simple heuristic: at least 50% of non-empty lines should match when trimmed)
  499. if (blockLines.length === findLines.length) {
  500. let matchingLines = 0
  501. let totalNonEmptyLines = 0
  502. for (let k = 1; k < blockLines.length - 1; k++) {
  503. const blockLine = blockLines[k].trim()
  504. const findLine = findLines[k].trim()
  505. if (blockLine.length > 0 || findLine.length > 0) {
  506. totalNonEmptyLines++
  507. if (blockLine === findLine) {
  508. matchingLines++
  509. }
  510. }
  511. }
  512. if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
  513. yield block
  514. break // Only match the first occurrence
  515. }
  516. }
  517. break
  518. }
  519. }
  520. }
  521. }
  522. export function trimDiff(diff: string): string {
  523. const lines = diff.split("\n")
  524. const contentLines = lines.filter(
  525. (line) =>
  526. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  527. !line.startsWith("---") &&
  528. !line.startsWith("+++"),
  529. )
  530. if (contentLines.length === 0) return diff
  531. let min = Infinity
  532. for (const line of contentLines) {
  533. const content = line.slice(1)
  534. if (content.trim().length > 0) {
  535. const match = content.match(/^(\s*)/)
  536. if (match) min = Math.min(min, match[1].length)
  537. }
  538. }
  539. if (min === Infinity || min === 0) return diff
  540. const trimmedLines = lines.map((line) => {
  541. if (
  542. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  543. !line.startsWith("---") &&
  544. !line.startsWith("+++")
  545. ) {
  546. const prefix = line[0]
  547. const content = line.slice(1)
  548. return prefix + content.slice(min)
  549. }
  550. return line
  551. })
  552. return trimmedLines.join("\n")
  553. }
  554. export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
  555. if (oldString === newString) {
  556. throw new Error("oldString and newString must be different")
  557. }
  558. let notFound = true
  559. for (const replacer of [
  560. SimpleReplacer,
  561. LineTrimmedReplacer,
  562. BlockAnchorReplacer,
  563. WhitespaceNormalizedReplacer,
  564. IndentationFlexibleReplacer,
  565. EscapeNormalizedReplacer,
  566. TrimmedBoundaryReplacer,
  567. ContextAwareReplacer,
  568. MultiOccurrenceReplacer,
  569. ]) {
  570. for (const search of replacer(content, oldString)) {
  571. const index = content.indexOf(search)
  572. if (index === -1) continue
  573. notFound = false
  574. if (replaceAll) {
  575. return content.replaceAll(search, newString)
  576. }
  577. const lastIndex = content.lastIndexOf(search)
  578. if (index !== lastIndex) continue
  579. return content.substring(0, index) + newString + content.substring(index + search.length)
  580. }
  581. }
  582. if (notFound) {
  583. throw new Error("oldString not found in content")
  584. }
  585. throw new Error(
  586. "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.",
  587. )
  588. }