edit.ts 19 KB

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