edit.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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. import { z } from "zod"
  4. import * as path from "path"
  5. import { Tool } from "./tool"
  6. import { FileTimes } from "./util/file-times"
  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. export const EditTool = Tool.define({
  13. id: "edit",
  14. description: DESCRIPTION,
  15. parameters: z.object({
  16. filePath: z.string().describe("The absolute path to the file to modify"),
  17. oldString: z.string().describe("The text to replace"),
  18. newString: z
  19. .string()
  20. .describe(
  21. "The text to replace it with (must be different from old_string)",
  22. ),
  23. replaceAll: z
  24. .boolean()
  25. .optional()
  26. .describe("Replace all occurrences of old_string (default false)"),
  27. }),
  28. async execute(params, ctx) {
  29. if (!params.filePath) {
  30. throw new Error("filePath is required")
  31. }
  32. const app = App.info()
  33. const filepath = path.isAbsolute(params.filePath)
  34. ? params.filePath
  35. : path.join(app.path.cwd, params.filePath)
  36. await Permission.ask({
  37. id: "edit",
  38. sessionID: ctx.sessionID,
  39. title: "Edit this file: " + filepath,
  40. metadata: {
  41. filePath: filepath,
  42. oldString: params.oldString,
  43. newString: params.newString,
  44. },
  45. })
  46. let contentOld = ""
  47. let contentNew = ""
  48. await (async () => {
  49. if (params.oldString === "") {
  50. contentNew = params.newString
  51. await Bun.write(filepath, params.newString)
  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())
  58. throw new Error(`Path is a directory, not a file: ${filepath}`)
  59. await FileTimes.assert(ctx.sessionID, filepath)
  60. contentOld = await file.text()
  61. contentNew = replace(
  62. contentOld,
  63. params.oldString,
  64. params.newString,
  65. params.replaceAll,
  66. )
  67. await file.write(contentNew)
  68. })()
  69. const diff = trimDiff(
  70. createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
  71. )
  72. FileTimes.read(ctx.sessionID, filepath)
  73. let output = ""
  74. await LSP.touchFile(filepath, true)
  75. const diagnostics = await LSP.diagnostics()
  76. for (const [file, issues] of Object.entries(diagnostics)) {
  77. if (issues.length === 0) continue
  78. if (file === filepath) {
  79. output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
  80. continue
  81. }
  82. output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
  83. }
  84. return {
  85. metadata: {
  86. diagnostics,
  87. diff,
  88. title: `${path.relative(app.path.root, filepath)}`,
  89. },
  90. output,
  91. }
  92. },
  93. })
  94. export type Replacer = (
  95. content: string,
  96. find: string,
  97. ) => Generator<string, void, unknown>
  98. export const SimpleReplacer: Replacer = function* (_content, find) {
  99. yield find
  100. }
  101. export const LineTrimmedReplacer: Replacer = function* (content, find) {
  102. const originalLines = content.split("\n")
  103. const searchLines = find.split("\n")
  104. if (searchLines[searchLines.length - 1] === "") {
  105. searchLines.pop()
  106. }
  107. for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
  108. let matches = true
  109. for (let j = 0; j < searchLines.length; j++) {
  110. const originalTrimmed = originalLines[i + j].trim()
  111. const searchTrimmed = searchLines[j].trim()
  112. if (originalTrimmed !== searchTrimmed) {
  113. matches = false
  114. break
  115. }
  116. }
  117. if (matches) {
  118. let matchStartIndex = 0
  119. for (let k = 0; k < i; k++) {
  120. matchStartIndex += originalLines[k].length + 1
  121. }
  122. let matchEndIndex = matchStartIndex
  123. for (let k = 0; k < searchLines.length; k++) {
  124. matchEndIndex += originalLines[i + k].length + 1
  125. }
  126. yield content.substring(matchStartIndex, matchEndIndex)
  127. }
  128. }
  129. }
  130. export const BlockAnchorReplacer: Replacer = function* (content, find) {
  131. const originalLines = content.split("\n")
  132. const searchLines = find.split("\n")
  133. if (searchLines.length < 3) {
  134. return
  135. }
  136. if (searchLines[searchLines.length - 1] === "") {
  137. searchLines.pop()
  138. }
  139. const firstLineSearch = searchLines[0].trim()
  140. const lastLineSearch = searchLines[searchLines.length - 1].trim()
  141. // Find blocks where first line matches the search first line
  142. for (let i = 0; i < originalLines.length; i++) {
  143. if (originalLines[i].trim() !== firstLineSearch) {
  144. continue
  145. }
  146. // Look for the matching last line after this first line
  147. for (let j = i + 2; j < originalLines.length; j++) {
  148. if (originalLines[j].trim() === lastLineSearch) {
  149. // Found a potential block from i to j
  150. let matchStartIndex = 0
  151. for (let k = 0; k < i; k++) {
  152. matchStartIndex += originalLines[k].length + 1
  153. }
  154. let matchEndIndex = matchStartIndex
  155. for (let k = 0; k <= j - i; k++) {
  156. matchEndIndex += originalLines[i + k].length + 1
  157. }
  158. yield content.substring(matchStartIndex, matchEndIndex)
  159. break // Only match the first occurrence of the last line
  160. }
  161. }
  162. }
  163. }
  164. export const WhitespaceNormalizedReplacer: Replacer = function* (
  165. content,
  166. find,
  167. ) {
  168. const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
  169. const normalizedFind = normalizeWhitespace(find)
  170. // Handle single line matches
  171. const lines = content.split("\n")
  172. for (let i = 0; i < lines.length; i++) {
  173. const line = lines[i]
  174. if (normalizeWhitespace(line) === normalizedFind) {
  175. yield line
  176. }
  177. // Also check for substring matches within lines
  178. const normalizedLine = normalizeWhitespace(line)
  179. if (normalizedLine.includes(normalizedFind)) {
  180. // Find the actual substring in the original line that matches
  181. const words = find.trim().split(/\s+/)
  182. if (words.length > 0) {
  183. const pattern = words
  184. .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
  185. .join("\\s+")
  186. const regex = new RegExp(pattern)
  187. const match = line.match(regex)
  188. if (match) {
  189. yield match[0]
  190. }
  191. }
  192. }
  193. }
  194. // Handle multi-line matches
  195. const findLines = find.split("\n")
  196. if (findLines.length > 1) {
  197. for (let i = 0; i <= lines.length - findLines.length; i++) {
  198. const block = lines.slice(i, i + findLines.length)
  199. if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
  200. yield block.join("\n")
  201. }
  202. }
  203. }
  204. }
  205. export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
  206. const removeIndentation = (text: string) => {
  207. const lines = text.split("\n")
  208. const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
  209. if (nonEmptyLines.length === 0) return text
  210. const minIndent = Math.min(
  211. ...nonEmptyLines.map((line) => {
  212. const match = line.match(/^(\s*)/)
  213. return match ? match[1].length : 0
  214. }),
  215. )
  216. return lines
  217. .map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
  218. .join("\n")
  219. }
  220. const normalizedFind = removeIndentation(find)
  221. const contentLines = content.split("\n")
  222. const findLines = find.split("\n")
  223. for (let i = 0; i <= contentLines.length - findLines.length; i++) {
  224. const block = contentLines.slice(i, i + findLines.length).join("\n")
  225. if (removeIndentation(block) === normalizedFind) {
  226. yield block
  227. }
  228. }
  229. }
  230. function trimDiff(diff: string): string {
  231. const lines = diff.split("\n")
  232. const contentLines = lines.filter(
  233. (line) =>
  234. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  235. !line.startsWith("---") &&
  236. !line.startsWith("+++"),
  237. )
  238. if (contentLines.length === 0) return diff
  239. let min = Infinity
  240. for (const line of contentLines) {
  241. const content = line.slice(1)
  242. if (content.trim().length > 0) {
  243. const match = content.match(/^(\s*)/)
  244. if (match) min = Math.min(min, match[1].length)
  245. }
  246. }
  247. if (min === Infinity || min === 0) return diff
  248. const trimmedLines = lines.map((line) => {
  249. if (
  250. (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
  251. !line.startsWith("---") &&
  252. !line.startsWith("+++")
  253. ) {
  254. const prefix = line[0]
  255. const content = line.slice(1)
  256. return prefix + content.slice(min)
  257. }
  258. return line
  259. })
  260. return trimmedLines.join("\n")
  261. }
  262. export function replace(
  263. content: string,
  264. oldString: string,
  265. newString: string,
  266. replaceAll = false,
  267. ): string {
  268. for (const replacer of [
  269. SimpleReplacer,
  270. LineTrimmedReplacer,
  271. BlockAnchorReplacer,
  272. WhitespaceNormalizedReplacer,
  273. IndentationFlexibleReplacer,
  274. ]) {
  275. for (const search of replacer(content, oldString)) {
  276. const index = content.indexOf(search)
  277. if (index === -1) continue
  278. if (replaceAll) {
  279. return content.replaceAll(search, newString)
  280. }
  281. const lastIndex = content.lastIndexOf(search)
  282. if (index !== lastIndex) continue
  283. return (
  284. content.substring(0, index) +
  285. newString +
  286. content.substring(index + search.length)
  287. )
  288. }
  289. }
  290. throw new Error("oldString not found in content or was found multiple times")
  291. }