edit.ts 20 KB

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