edit.ts 19 KB

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