edit.ts 20 KB

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