index.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. import z from "zod"
  2. import * as path from "path"
  3. import * as fs from "fs/promises"
  4. import { Log } from "../util/log"
  5. export namespace Patch {
  6. const log = Log.create({ service: "patch" })
  7. // Schema definitions
  8. export const PatchSchema = z.object({
  9. patchText: z.string().describe("The full patch text that describes all changes to be made"),
  10. })
  11. export type PatchParams = z.infer<typeof PatchSchema>
  12. // Core types matching the Rust implementation
  13. export interface ApplyPatchArgs {
  14. patch: string
  15. hunks: Hunk[]
  16. workdir?: string
  17. }
  18. export type Hunk =
  19. | { type: "add"; path: string; contents: string }
  20. | { type: "delete"; path: string }
  21. | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
  22. export interface UpdateFileChunk {
  23. old_lines: string[]
  24. new_lines: string[]
  25. change_context?: string
  26. is_end_of_file?: boolean
  27. }
  28. export interface ApplyPatchAction {
  29. changes: Map<string, ApplyPatchFileChange>
  30. patch: string
  31. cwd: string
  32. }
  33. export type ApplyPatchFileChange =
  34. | { type: "add"; content: string }
  35. | { type: "delete"; content: string }
  36. | { type: "update"; unified_diff: string; move_path?: string; new_content: string }
  37. export interface AffectedPaths {
  38. added: string[]
  39. modified: string[]
  40. deleted: string[]
  41. }
  42. export enum ApplyPatchError {
  43. ParseError = "ParseError",
  44. IoError = "IoError",
  45. ComputeReplacements = "ComputeReplacements",
  46. ImplicitInvocation = "ImplicitInvocation",
  47. }
  48. export enum MaybeApplyPatch {
  49. Body = "Body",
  50. ShellParseError = "ShellParseError",
  51. PatchParseError = "PatchParseError",
  52. NotApplyPatch = "NotApplyPatch",
  53. }
  54. export enum MaybeApplyPatchVerified {
  55. Body = "Body",
  56. ShellParseError = "ShellParseError",
  57. CorrectnessError = "CorrectnessError",
  58. NotApplyPatch = "NotApplyPatch",
  59. }
  60. // Parser implementation
  61. function parsePatchHeader(
  62. lines: string[],
  63. startIdx: number,
  64. ): { filePath: string; movePath?: string; nextIdx: number } | null {
  65. const line = lines[startIdx]
  66. if (line.startsWith("*** Add File:")) {
  67. const filePath = line.split(":", 2)[1]?.trim()
  68. return filePath ? { filePath, nextIdx: startIdx + 1 } : null
  69. }
  70. if (line.startsWith("*** Delete File:")) {
  71. const filePath = line.split(":", 2)[1]?.trim()
  72. return filePath ? { filePath, nextIdx: startIdx + 1 } : null
  73. }
  74. if (line.startsWith("*** Update File:")) {
  75. const filePath = line.split(":", 2)[1]?.trim()
  76. let movePath: string | undefined
  77. let nextIdx = startIdx + 1
  78. // Check for move directive
  79. if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
  80. movePath = lines[nextIdx].split(":", 2)[1]?.trim()
  81. nextIdx++
  82. }
  83. return filePath ? { filePath, movePath, nextIdx } : null
  84. }
  85. return null
  86. }
  87. function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
  88. const chunks: UpdateFileChunk[] = []
  89. let i = startIdx
  90. while (i < lines.length && !lines[i].startsWith("***")) {
  91. if (lines[i].startsWith("@@")) {
  92. // Parse context line
  93. const contextLine = lines[i].substring(2).trim()
  94. i++
  95. const oldLines: string[] = []
  96. const newLines: string[] = []
  97. let isEndOfFile = false
  98. // Parse change lines
  99. while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
  100. const changeLine = lines[i]
  101. if (changeLine === "*** End of File") {
  102. isEndOfFile = true
  103. i++
  104. break
  105. }
  106. if (changeLine.startsWith(" ")) {
  107. // Keep line - appears in both old and new
  108. const content = changeLine.substring(1)
  109. oldLines.push(content)
  110. newLines.push(content)
  111. } else if (changeLine.startsWith("-")) {
  112. // Remove line - only in old
  113. oldLines.push(changeLine.substring(1))
  114. } else if (changeLine.startsWith("+")) {
  115. // Add line - only in new
  116. newLines.push(changeLine.substring(1))
  117. }
  118. i++
  119. }
  120. chunks.push({
  121. old_lines: oldLines,
  122. new_lines: newLines,
  123. change_context: contextLine || undefined,
  124. is_end_of_file: isEndOfFile || undefined,
  125. })
  126. } else {
  127. i++
  128. }
  129. }
  130. return { chunks, nextIdx: i }
  131. }
  132. function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
  133. let content = ""
  134. let i = startIdx
  135. while (i < lines.length && !lines[i].startsWith("***")) {
  136. if (lines[i].startsWith("+")) {
  137. content += lines[i].substring(1) + "\n"
  138. }
  139. i++
  140. }
  141. // Remove trailing newline
  142. if (content.endsWith("\n")) {
  143. content = content.slice(0, -1)
  144. }
  145. return { content, nextIdx: i }
  146. }
  147. export function parsePatch(patchText: string): { hunks: Hunk[] } {
  148. const lines = patchText.split("\n")
  149. const hunks: Hunk[] = []
  150. let i = 0
  151. // Look for Begin/End patch markers
  152. const beginMarker = "*** Begin Patch"
  153. const endMarker = "*** End Patch"
  154. const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
  155. const endIdx = lines.findIndex((line) => line.trim() === endMarker)
  156. if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
  157. throw new Error("Invalid patch format: missing Begin/End markers")
  158. }
  159. // Parse content between markers
  160. i = beginIdx + 1
  161. while (i < endIdx) {
  162. const header = parsePatchHeader(lines, i)
  163. if (!header) {
  164. i++
  165. continue
  166. }
  167. if (lines[i].startsWith("*** Add File:")) {
  168. const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
  169. hunks.push({
  170. type: "add",
  171. path: header.filePath,
  172. contents: content,
  173. })
  174. i = nextIdx
  175. } else if (lines[i].startsWith("*** Delete File:")) {
  176. hunks.push({
  177. type: "delete",
  178. path: header.filePath,
  179. })
  180. i = header.nextIdx
  181. } else if (lines[i].startsWith("*** Update File:")) {
  182. const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
  183. hunks.push({
  184. type: "update",
  185. path: header.filePath,
  186. move_path: header.movePath,
  187. chunks,
  188. })
  189. i = nextIdx
  190. } else {
  191. i++
  192. }
  193. }
  194. return { hunks }
  195. }
  196. // Apply patch functionality
  197. export function maybeParseApplyPatch(
  198. argv: string[],
  199. ):
  200. | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
  201. | { type: MaybeApplyPatch.PatchParseError; error: Error }
  202. | { type: MaybeApplyPatch.NotApplyPatch } {
  203. const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
  204. // Direct invocation: apply_patch <patch>
  205. if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
  206. try {
  207. const { hunks } = parsePatch(argv[1])
  208. return {
  209. type: MaybeApplyPatch.Body,
  210. args: {
  211. patch: argv[1],
  212. hunks,
  213. },
  214. }
  215. } catch (error) {
  216. return {
  217. type: MaybeApplyPatch.PatchParseError,
  218. error: error as Error,
  219. }
  220. }
  221. }
  222. // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
  223. if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
  224. // Simple extraction - in real implementation would need proper bash parsing
  225. const script = argv[2]
  226. const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
  227. if (heredocMatch) {
  228. const patchContent = heredocMatch[2]
  229. try {
  230. const { hunks } = parsePatch(patchContent)
  231. return {
  232. type: MaybeApplyPatch.Body,
  233. args: {
  234. patch: patchContent,
  235. hunks,
  236. },
  237. }
  238. } catch (error) {
  239. return {
  240. type: MaybeApplyPatch.PatchParseError,
  241. error: error as Error,
  242. }
  243. }
  244. }
  245. }
  246. return { type: MaybeApplyPatch.NotApplyPatch }
  247. }
  248. // File content manipulation
  249. interface ApplyPatchFileUpdate {
  250. unified_diff: string
  251. content: string
  252. }
  253. export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
  254. // Read original file content
  255. let originalContent: string
  256. try {
  257. originalContent = require("fs").readFileSync(filePath, "utf-8")
  258. } catch (error) {
  259. throw new Error(`Failed to read file ${filePath}: ${error}`)
  260. }
  261. let originalLines = originalContent.split("\n")
  262. // Drop trailing empty element for consistent line counting
  263. if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
  264. originalLines.pop()
  265. }
  266. const replacements = computeReplacements(originalLines, filePath, chunks)
  267. let newLines = applyReplacements(originalLines, replacements)
  268. // Ensure trailing newline
  269. if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
  270. newLines.push("")
  271. }
  272. const newContent = newLines.join("\n")
  273. // Generate unified diff
  274. const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
  275. return {
  276. unified_diff: unifiedDiff,
  277. content: newContent,
  278. }
  279. }
  280. function computeReplacements(
  281. originalLines: string[],
  282. filePath: string,
  283. chunks: UpdateFileChunk[],
  284. ): Array<[number, number, string[]]> {
  285. const replacements: Array<[number, number, string[]]> = []
  286. let lineIndex = 0
  287. for (const chunk of chunks) {
  288. // Handle context-based seeking
  289. if (chunk.change_context) {
  290. const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
  291. if (contextIdx === -1) {
  292. throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
  293. }
  294. lineIndex = contextIdx + 1
  295. }
  296. // Handle pure addition (no old lines)
  297. if (chunk.old_lines.length === 0) {
  298. const insertionIdx =
  299. originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
  300. ? originalLines.length - 1
  301. : originalLines.length
  302. replacements.push([insertionIdx, 0, chunk.new_lines])
  303. continue
  304. }
  305. // Try to match old lines in the file
  306. let pattern = chunk.old_lines
  307. let newSlice = chunk.new_lines
  308. let found = seekSequence(originalLines, pattern, lineIndex)
  309. // Retry without trailing empty line if not found
  310. if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
  311. pattern = pattern.slice(0, -1)
  312. if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
  313. newSlice = newSlice.slice(0, -1)
  314. }
  315. found = seekSequence(originalLines, pattern, lineIndex)
  316. }
  317. if (found !== -1) {
  318. replacements.push([found, pattern.length, newSlice])
  319. lineIndex = found + pattern.length
  320. } else {
  321. throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
  322. }
  323. }
  324. // Sort replacements by index to apply in order
  325. replacements.sort((a, b) => a[0] - b[0])
  326. return replacements
  327. }
  328. function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
  329. // Apply replacements in reverse order to avoid index shifting
  330. const result = [...lines]
  331. for (let i = replacements.length - 1; i >= 0; i--) {
  332. const [startIdx, oldLen, newSegment] = replacements[i]
  333. // Remove old lines
  334. result.splice(startIdx, oldLen)
  335. // Insert new lines
  336. for (let j = 0; j < newSegment.length; j++) {
  337. result.splice(startIdx + j, 0, newSegment[j])
  338. }
  339. }
  340. return result
  341. }
  342. function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
  343. if (pattern.length === 0) return -1
  344. // Simple substring search implementation
  345. for (let i = startIndex; i <= lines.length - pattern.length; i++) {
  346. let matches = true
  347. for (let j = 0; j < pattern.length; j++) {
  348. if (lines[i + j] !== pattern[j]) {
  349. matches = false
  350. break
  351. }
  352. }
  353. if (matches) {
  354. return i
  355. }
  356. }
  357. return -1
  358. }
  359. function generateUnifiedDiff(oldContent: string, newContent: string): string {
  360. const oldLines = oldContent.split("\n")
  361. const newLines = newContent.split("\n")
  362. // Simple diff generation - in a real implementation you'd use a proper diff algorithm
  363. let diff = "@@ -1 +1 @@\n"
  364. // Find changes (simplified approach)
  365. const maxLen = Math.max(oldLines.length, newLines.length)
  366. let hasChanges = false
  367. for (let i = 0; i < maxLen; i++) {
  368. const oldLine = oldLines[i] || ""
  369. const newLine = newLines[i] || ""
  370. if (oldLine !== newLine) {
  371. if (oldLine) diff += `-${oldLine}\n`
  372. if (newLine) diff += `+${newLine}\n`
  373. hasChanges = true
  374. } else if (oldLine) {
  375. diff += ` ${oldLine}\n`
  376. }
  377. }
  378. return hasChanges ? diff : ""
  379. }
  380. // Apply hunks to filesystem
  381. export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
  382. if (hunks.length === 0) {
  383. throw new Error("No files were modified.")
  384. }
  385. const added: string[] = []
  386. const modified: string[] = []
  387. const deleted: string[] = []
  388. for (const hunk of hunks) {
  389. switch (hunk.type) {
  390. case "add":
  391. // Create parent directories
  392. const addDir = path.dirname(hunk.path)
  393. if (addDir !== "." && addDir !== "/") {
  394. await fs.mkdir(addDir, { recursive: true })
  395. }
  396. await fs.writeFile(hunk.path, hunk.contents, "utf-8")
  397. added.push(hunk.path)
  398. log.info(`Added file: ${hunk.path}`)
  399. break
  400. case "delete":
  401. await fs.unlink(hunk.path)
  402. deleted.push(hunk.path)
  403. log.info(`Deleted file: ${hunk.path}`)
  404. break
  405. case "update":
  406. const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
  407. if (hunk.move_path) {
  408. // Handle file move
  409. const moveDir = path.dirname(hunk.move_path)
  410. if (moveDir !== "." && moveDir !== "/") {
  411. await fs.mkdir(moveDir, { recursive: true })
  412. }
  413. await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
  414. await fs.unlink(hunk.path)
  415. modified.push(hunk.move_path)
  416. log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
  417. } else {
  418. // Regular update
  419. await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
  420. modified.push(hunk.path)
  421. log.info(`Updated file: ${hunk.path}`)
  422. }
  423. break
  424. }
  425. }
  426. return { added, modified, deleted }
  427. }
  428. // Main patch application function
  429. export async function applyPatch(patchText: string): Promise<AffectedPaths> {
  430. const { hunks } = parsePatch(patchText)
  431. return applyHunksToFiles(hunks)
  432. }
  433. // Async version of maybeParseApplyPatchVerified
  434. export async function maybeParseApplyPatchVerified(
  435. argv: string[],
  436. cwd: string,
  437. ): Promise<
  438. | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
  439. | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
  440. | { type: MaybeApplyPatchVerified.NotApplyPatch }
  441. > {
  442. // Detect implicit patch invocation (raw patch without apply_patch command)
  443. if (argv.length === 1) {
  444. try {
  445. parsePatch(argv[0])
  446. return {
  447. type: MaybeApplyPatchVerified.CorrectnessError,
  448. error: new Error(ApplyPatchError.ImplicitInvocation),
  449. }
  450. } catch {
  451. // Not a patch, continue
  452. }
  453. }
  454. const result = maybeParseApplyPatch(argv)
  455. switch (result.type) {
  456. case MaybeApplyPatch.Body:
  457. const { args } = result
  458. const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
  459. const changes = new Map<string, ApplyPatchFileChange>()
  460. for (const hunk of args.hunks) {
  461. const resolvedPath = path.resolve(
  462. effectiveCwd,
  463. hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
  464. )
  465. switch (hunk.type) {
  466. case "add":
  467. changes.set(resolvedPath, {
  468. type: "add",
  469. content: hunk.contents,
  470. })
  471. break
  472. case "delete":
  473. // For delete, we need to read the current content
  474. const deletePath = path.resolve(effectiveCwd, hunk.path)
  475. try {
  476. const content = await fs.readFile(deletePath, "utf-8")
  477. changes.set(resolvedPath, {
  478. type: "delete",
  479. content,
  480. })
  481. } catch (error) {
  482. return {
  483. type: MaybeApplyPatchVerified.CorrectnessError,
  484. error: new Error(`Failed to read file for deletion: ${deletePath}`),
  485. }
  486. }
  487. break
  488. case "update":
  489. const updatePath = path.resolve(effectiveCwd, hunk.path)
  490. try {
  491. const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
  492. changes.set(resolvedPath, {
  493. type: "update",
  494. unified_diff: fileUpdate.unified_diff,
  495. move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
  496. new_content: fileUpdate.content,
  497. })
  498. } catch (error) {
  499. return {
  500. type: MaybeApplyPatchVerified.CorrectnessError,
  501. error: error as Error,
  502. }
  503. }
  504. break
  505. }
  506. }
  507. return {
  508. type: MaybeApplyPatchVerified.Body,
  509. action: {
  510. changes,
  511. patch: args.patch,
  512. cwd: effectiveCwd,
  513. },
  514. }
  515. case MaybeApplyPatch.PatchParseError:
  516. return {
  517. type: MaybeApplyPatchVerified.CorrectnessError,
  518. error: result.error,
  519. }
  520. case MaybeApplyPatch.NotApplyPatch:
  521. return { type: MaybeApplyPatchVerified.NotApplyPatch }
  522. }
  523. }
  524. }