edit.ts 21 KB

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