edit.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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. import { z } from "zod"
  5. import * as path from "path"
  6. import { Tool } from "./tool"
  7. import { LSP } from "../lsp"
  8. import { createTwoFilesPatch } from "diff"
  9. import { Permission } from "../permission"
  10. import DESCRIPTION from "./edit.txt"
  11. import { App } from "../app/app"
  12. import { File } from "../file"
  13. import { Bus } from "../bus"
  14. import { FileTime } from "../file/time"
  15. export const EditTool = Tool.define({
  16. id: "edit",
  17. description: DESCRIPTION,
  18. parameters: z.object({
  19. filePath: z.string().describe("The absolute path to the file to modify"),
  20. oldString: z.string().describe("The text to replace"),
  21. newString: z.string().describe("The text to replace it with (must be different from oldString)"),
  22. replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
  23. }),
  24. async execute(params, ctx) {
  25. if (!params.filePath) {
  26. throw new Error("filePath is required")
  27. }
  28. if (params.oldString === params.newString) {
  29. throw new Error("oldString and newString must be different")
  30. }
  31. const app = App.info()
  32. const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
  33. await Permission.ask({
  34. id: "edit",
  35. sessionID: ctx.sessionID,
  36. title: "Edit this file: " + filepath,
  37. metadata: {
  38. filePath: filepath,
  39. oldString: params.oldString,
  40. newString: params.newString,
  41. },
  42. })
  43. let contentOld = ""
  44. let contentNew = ""
  45. await (async () => {
  46. if (params.oldString === "") {
  47. contentNew = params.newString
  48. await Bun.write(filepath, params.newString)
  49. await Bus.publish(File.Event.Edited, {
  50. file: filepath,
  51. })
  52. return
  53. }
  54. const file = Bun.file(filepath)
  55. const stats = await file.stat().catch(() => {})
  56. if (!stats) throw new Error(`File ${filepath} not found`)
  57. if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
  58. await FileTime.assert(ctx.sessionID, filepath)
  59. contentOld = await file.text()
  60. contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
  61. await file.write(contentNew)
  62. await Bus.publish(File.Event.Edited, {
  63. file: filepath,
  64. })
  65. contentNew = await file.text()
  66. })()
  67. const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
  68. FileTime.read(ctx.sessionID, filepath)
  69. let output = ""
  70. await LSP.touchFile(filepath, true)
  71. const diagnostics = await LSP.diagnostics()
  72. for (const [file, issues] of Object.entries(diagnostics)) {
  73. if (issues.length === 0) continue
  74. if (file === filepath) {
  75. output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
  76. continue
  77. }
  78. output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
  79. }
  80. return {
  81. metadata: {
  82. diagnostics,
  83. diff,
  84. },
  85. title: `${path.relative(app.path.root, filepath)}`,
  86. output,
  87. }
  88. },
  89. })
  90. export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
  91. export const SimpleReplacer: Replacer = function* (_content, find) {
  92. yield find
  93. }
  94. export const LineTrimmedReplacer: Replacer = function* (content, find) {
  95. const originalLines = content.split("\n")
  96. const searchLines = find.split("\n")
  97. if (searchLines[searchLines.length - 1] === "") {
  98. searchLines.pop()
  99. }
  100. for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
  101. let matches = true
  102. for (let j = 0; j < searchLines.length; j++) {
  103. const originalTrimmed = originalLines[i + j].trim()
  104. const searchTrimmed = searchLines[j].trim()
  105. if (originalTrimmed !== searchTrimmed) {
  106. matches = false
  107. break
  108. }
  109. }
  110. if (matches) {
  111. let matchStartIndex = 0
  112. for (let k = 0; k < i; k++) {
  113. matchStartIndex += originalLines[k].length + 1
  114. }
  115. let matchEndIndex = matchStartIndex
  116. for (let k = 0; k < searchLines.length; k++) {
  117. matchEndIndex += originalLines[i + k].length + 1
  118. }
  119. yield content.substring(matchStartIndex, matchEndIndex)
  120. }
  121. }
  122. }
  123. export const BlockAnchorReplacer: Replacer = function* (content, find) {
  124. const originalLines = content.split("\n")
  125. const searchLines = find.split("\n")
  126. if (searchLines.length < 3) {
  127. return
  128. }
  129. if (searchLines[searchLines.length - 1] === "") {
  130. searchLines.pop()
  131. }
  132. const firstLineSearch = searchLines[0].trim()
  133. const lastLineSearch = searchLines[searchLines.length - 1].trim()
  134. // Find blocks where first line matches the search first line
  135. for (let i = 0; i < originalLines.length; i++) {
  136. if (originalLines[i].trim() !== firstLineSearch) {
  137. continue
  138. }
  139. // Look for the matching last line after this first line
  140. for (let j = i + 2; j < originalLines.length; j++) {
  141. if (originalLines[j].trim() === lastLineSearch) {
  142. // Found a potential block from i to j
  143. let matchStartIndex = 0
  144. for (let k = 0; k < i; k++) {
  145. matchStartIndex += originalLines[k].length + 1
  146. }
  147. let matchEndIndex = matchStartIndex
  148. for (let k = 0; k <= j - i; k++) {
  149. matchEndIndex += originalLines[i + k].length
  150. if (k < j - i) {
  151. matchEndIndex += 1 // Add newline character except for the last line
  152. }
  153. }
  154. yield content.substring(matchStartIndex, matchEndIndex)
  155. break // Only match the first occurrence of the last line
  156. }
  157. }
  158. }
  159. }
  160. export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
  161. const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
  162. const normalizedFind = normalizeWhitespace(find)
  163. // Handle single line matches
  164. const lines = content.split("\n")
  165. for (let i = 0; i < lines.length; i++) {
  166. const line = lines[i]
  167. if (normalizeWhitespace(line) === normalizedFind) {
  168. yield line
  169. }
  170. // Also check for substring matches within lines
  171. const normalizedLine = normalizeWhitespace(line)
  172. if (normalizedLine.includes(normalizedFind)) {
  173. // Find the actual substring in the original line that matches
  174. const words = find.trim().split(/\s+/)
  175. if (words.length > 0) {
  176. const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
  177. try {
  178. const regex = new RegExp(pattern)
  179. const match = line.match(regex)
  180. if (match) {
  181. yield match[0]
  182. }
  183. } catch (e) {
  184. // Invalid regex pattern, skip
  185. }
  186. }
  187. }
  188. }
  189. // Handle multi-line matches
  190. const findLines = find.split("\n")
  191. if (findLines.length > 1) {
  192. for (let i = 0; i <= lines.length - findLines.length; i++) {
  193. const block = lines.slice(i, i + findLines.length)
  194. if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
  195. yield block.join("\n")
  196. }
  197. }
  198. }
  199. }
  200. export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
  201. const removeIndentation = (text: string) => {
  202. const lines = text.split("\n")
  203. const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
  204. if (nonEmptyLines.length === 0) return text
  205. const minIndent = Math.min(
  206. ...nonEmptyLines.map((line) => {
  207. const match = line.match(/^(\s*)/)
  208. return match ? match[1].length : 0
  209. }),
  210. )
  211. return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
  212. }
  213. const normalizedFind = removeIndentation(find)
  214. const contentLines = content.split("\n")
  215. const findLines = find.split("\n")
  216. for (let i = 0; i <= contentLines.length - findLines.length; i++) {
  217. const block = contentLines.slice(i, i + findLines.length).join("\n")
  218. if (removeIndentation(block) === normalizedFind) {
  219. yield block
  220. }
  221. }
  222. }
  223. export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
  224. const unescapeString = (str: string): string => {
  225. return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
  226. switch (capturedChar) {
  227. case "n":
  228. return "\n"
  229. case "t":
  230. return "\t"
  231. case "r":
  232. return "\r"
  233. case "'":
  234. return "'"
  235. case '"':
  236. return '"'
  237. case "`":
  238. return "`"
  239. case "\\":
  240. return "\\"
  241. case "\n":
  242. return "\n"
  243. case "$":
  244. return "$"
  245. default:
  246. return match
  247. }
  248. })
  249. }
  250. const unescapedFind = unescapeString(find)
  251. // Try direct match with unescaped find string
  252. if (content.includes(unescapedFind)) {
  253. yield unescapedFind
  254. }
  255. // Also try finding escaped versions in content that match unescaped find
  256. const lines = content.split("\n")
  257. const findLines = unescapedFind.split("\n")
  258. for (let i = 0; i <= lines.length - findLines.length; i++) {
  259. const block = lines.slice(i, i + findLines.length).join("\n")
  260. const unescapedBlock = unescapeString(block)
  261. if (unescapedBlock === unescapedFind) {
  262. yield block
  263. }
  264. }
  265. }
  266. export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
  267. // This replacer yields all exact matches, allowing the replace function
  268. // to handle multiple occurrences based on replaceAll parameter
  269. let startIndex = 0
  270. while (true) {
  271. const index = content.indexOf(find, startIndex)
  272. if (index === -1) break
  273. yield find
  274. startIndex = index + find.length
  275. }
  276. }
  277. export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
  278. const trimmedFind = find.trim()
  279. if (trimmedFind === find) {
  280. // Already trimmed, no point in trying
  281. return
  282. }
  283. // Try to find the trimmed version
  284. if (content.includes(trimmedFind)) {
  285. yield trimmedFind
  286. }
  287. // Also try finding blocks where trimmed content matches
  288. const lines = content.split("\n")
  289. const findLines = find.split("\n")
  290. for (let i = 0; i <= lines.length - findLines.length; i++) {
  291. const block = lines.slice(i, i + findLines.length).join("\n")
  292. if (block.trim() === trimmedFind) {
  293. yield block
  294. }
  295. }
  296. }
  297. export const ContextAwareReplacer: Replacer = function* (content, find) {
  298. const findLines = find.split("\n")
  299. if (findLines.length < 3) {
  300. // Need at least 3 lines to have meaningful context
  301. return
  302. }
  303. // Remove trailing empty line if present
  304. if (findLines[findLines.length - 1] === "") {
  305. findLines.pop()
  306. }
  307. const contentLines = content.split("\n")
  308. // Extract first and last lines as context anchors
  309. const firstLine = findLines[0].trim()
  310. const lastLine = findLines[findLines.length - 1].trim()
  311. // Find blocks that start and end with the context anchors
  312. for (let i = 0; i < contentLines.length; i++) {
  313. if (contentLines[i].trim() !== firstLine) continue
  314. // Look for the matching last line
  315. for (let j = i + 2; j < contentLines.length; j++) {
  316. if (contentLines[j].trim() === lastLine) {
  317. // Found a potential context block
  318. const blockLines = contentLines.slice(i, j + 1)
  319. const block = blockLines.join("\n")
  320. // Check if the middle content has reasonable similarity
  321. // (simple heuristic: at least 50% of non-empty lines should match when trimmed)
  322. if (blockLines.length === findLines.length) {
  323. let matchingLines = 0
  324. let totalNonEmptyLines = 0
  325. for (let k = 1; k < blockLines.length - 1; k++) {
  326. const blockLine = blockLines[k].trim()
  327. const findLine = findLines[k].trim()
  328. if (blockLine.length > 0 || findLine.length > 0) {
  329. totalNonEmptyLines++
  330. if (blockLine === findLine) {
  331. matchingLines++
  332. }
  333. }
  334. }
  335. if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
  336. yield block
  337. break // Only match the first occurrence
  338. }
  339. }
  340. break
  341. }
  342. }
  343. }
  344. }
  345. function trimDiff(diff: string): string {
  346. const lines = diff.split("\n")
  347. const contentLines = lines.filter(
  348. (line) =>
  349. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  350. !line.startsWith("---") &&
  351. !line.startsWith("+++"),
  352. )
  353. if (contentLines.length === 0) return diff
  354. let min = Infinity
  355. for (const line of contentLines) {
  356. const content = line.slice(1)
  357. if (content.trim().length > 0) {
  358. const match = content.match(/^(\s*)/)
  359. if (match) min = Math.min(min, match[1].length)
  360. }
  361. }
  362. if (min === Infinity || min === 0) return diff
  363. const trimmedLines = lines.map((line) => {
  364. if (
  365. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  366. !line.startsWith("---") &&
  367. !line.startsWith("+++")
  368. ) {
  369. const prefix = line[0]
  370. const content = line.slice(1)
  371. return prefix + content.slice(min)
  372. }
  373. return line
  374. })
  375. return trimmedLines.join("\n")
  376. }
  377. export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
  378. if (oldString === newString) {
  379. throw new Error("oldString and newString must be different")
  380. }
  381. for (const replacer of [
  382. SimpleReplacer,
  383. LineTrimmedReplacer,
  384. BlockAnchorReplacer,
  385. WhitespaceNormalizedReplacer,
  386. IndentationFlexibleReplacer,
  387. // EscapeNormalizedReplacer,
  388. // TrimmedBoundaryReplacer,
  389. // ContextAwareReplacer,
  390. // MultiOccurrenceReplacer,
  391. ]) {
  392. for (const search of replacer(content, oldString)) {
  393. const index = content.indexOf(search)
  394. if (index === -1) continue
  395. if (replaceAll) {
  396. return content.replaceAll(search, newString)
  397. }
  398. const lastIndex = content.lastIndexOf(search)
  399. if (index !== lastIndex) continue
  400. return content.substring(0, index) + newString + content.substring(index + search.length)
  401. }
  402. }
  403. throw new Error("oldString not found in content or was found multiple times")
  404. }