patch.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. export * as Patch from "./patch"
  2. export type Hunk =
  3. | { readonly type: "add"; readonly path: string; readonly contents: string }
  4. | { readonly type: "delete"; readonly path: string }
  5. | {
  6. readonly type: "update"
  7. readonly path: string
  8. readonly movePath?: string
  9. readonly chunks: ReadonlyArray<UpdateFileChunk>
  10. }
  11. export interface UpdateFileChunk {
  12. readonly oldLines: ReadonlyArray<string>
  13. readonly newLines: ReadonlyArray<string>
  14. readonly changeContext?: string
  15. readonly endOfFile?: boolean
  16. }
  17. export interface FileUpdate {
  18. readonly content: string
  19. readonly bom: boolean
  20. }
  21. export function parse(patchText: string): ReadonlyArray<Hunk> {
  22. const lines = stripHeredoc(patchText.trim()).split("\n")
  23. const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
  24. const end = lines.findIndex((line) => line.trim() === "*** End Patch")
  25. if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
  26. const hunks: Hunk[] = []
  27. let index = begin + 1
  28. while (index < end) {
  29. const line = lines[index]!
  30. if (line.startsWith("*** Add File:")) {
  31. const path = line.slice("*** Add File:".length).trim()
  32. if (!path) throw new Error("Invalid add file path")
  33. const parsed = parseAdd(lines, index + 1)
  34. hunks.push({ type: "add", path, contents: parsed.content })
  35. index = parsed.next
  36. continue
  37. }
  38. if (line.startsWith("*** Delete File:")) {
  39. const path = line.slice("*** Delete File:".length).trim()
  40. if (!path) throw new Error("Invalid delete file path")
  41. hunks.push({ type: "delete", path })
  42. index++
  43. continue
  44. }
  45. if (line.startsWith("*** Update File:")) {
  46. const path = line.slice("*** Update File:".length).trim()
  47. if (!path) throw new Error("Invalid update file path")
  48. let next = index + 1
  49. let movePath: string | undefined
  50. if (lines[next]?.startsWith("*** Move to:")) {
  51. movePath = lines[next]!.slice("*** Move to:".length).trim()
  52. if (!movePath) throw new Error("Invalid move file path")
  53. next++
  54. }
  55. const parsed = parseUpdate(lines, next)
  56. if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
  57. hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
  58. index = parsed.next
  59. continue
  60. }
  61. throw new Error(`Invalid patch line: ${line}`)
  62. }
  63. return hunks
  64. }
  65. export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
  66. const source = splitBom(original)
  67. const lines = source.text.split("\n")
  68. if (lines.at(-1) === "") lines.pop()
  69. const replacements = computeReplacements(lines, path, chunks)
  70. const updated = [...lines]
  71. for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
  72. if (updated.at(-1) !== "") updated.push("")
  73. const next = splitBom(updated.join("\n"))
  74. return { content: next.text, bom: source.bom || next.bom }
  75. }
  76. export function joinBom(text: string, bom: boolean) {
  77. const stripped = splitBom(text).text
  78. return bom ? `\uFEFF${stripped}` : stripped
  79. }
  80. function parseAdd(lines: ReadonlyArray<string>, start: number) {
  81. const content: string[] = []
  82. let index = start
  83. while (index < lines.length && !lines[index]!.startsWith("***")) {
  84. if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
  85. content.push(lines[index]!.slice(1))
  86. index++
  87. }
  88. return { content: content.join("\n"), next: index }
  89. }
  90. function parseUpdate(lines: ReadonlyArray<string>, start: number) {
  91. const chunks: UpdateFileChunk[] = []
  92. let index = start
  93. while (index < lines.length && !lines[index]!.startsWith("***")) {
  94. if (!lines[index]!.startsWith("@@")) {
  95. throw new Error(`Invalid update file line: ${lines[index]}`)
  96. }
  97. const changeContext = lines[index]!.slice(2).trim() || undefined
  98. const oldLines: string[] = []
  99. const newLines: string[] = []
  100. let endOfFile = false
  101. index++
  102. while (index < lines.length && !lines[index]!.startsWith("@@")) {
  103. const line = lines[index]!
  104. if (line === "*** End of File") {
  105. endOfFile = true
  106. index++
  107. break
  108. }
  109. if (line.startsWith("***")) break
  110. if (line.startsWith(" ")) {
  111. oldLines.push(line.slice(1))
  112. newLines.push(line.slice(1))
  113. } else if (line.startsWith("-")) oldLines.push(line.slice(1))
  114. else if (line.startsWith("+")) newLines.push(line.slice(1))
  115. else throw new Error(`Invalid update chunk line: ${line}`)
  116. index++
  117. }
  118. chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
  119. }
  120. return { chunks, next: index }
  121. }
  122. function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
  123. const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
  124. let lineIndex = 0
  125. for (const chunk of chunks) {
  126. if (chunk.changeContext) {
  127. const context = seek(lines, [chunk.changeContext], lineIndex)
  128. if (context === -1) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`)
  129. lineIndex = context + 1
  130. }
  131. if (chunk.oldLines.length === 0) {
  132. replacements.push([lines.length, 0, chunk.newLines])
  133. continue
  134. }
  135. let oldLines = chunk.oldLines
  136. let newLines = chunk.newLines
  137. let found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
  138. if (found === -1 && oldLines.at(-1) === "") {
  139. oldLines = oldLines.slice(0, -1)
  140. if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
  141. found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
  142. }
  143. if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
  144. replacements.push([found, oldLines.length, newLines])
  145. lineIndex = found + oldLines.length
  146. }
  147. return replacements.toSorted((left, right) => left[0] - right[0])
  148. }
  149. function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
  150. if (pattern.length === 0) return -1
  151. for (const compare of [exact, rstrip, trim, normalized]) {
  152. if (eof) {
  153. const offset = lines.length - pattern.length
  154. if (offset >= start && matches(lines, pattern, offset, compare)) return offset
  155. }
  156. for (let offset = start; offset <= lines.length - pattern.length; offset++) {
  157. if (matches(lines, pattern, offset, compare)) return offset
  158. }
  159. }
  160. return -1
  161. }
  162. function matches(
  163. lines: ReadonlyArray<string>,
  164. pattern: ReadonlyArray<string>,
  165. offset: number,
  166. compare: (left: string, right: string) => boolean,
  167. ) {
  168. return pattern.every((line, index) => compare(lines[offset + index]!, line))
  169. }
  170. const exact = (left: string, right: string) => left === right
  171. const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
  172. const trim = (left: string, right: string) => left.trim() === right.trim()
  173. const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
  174. const normalize = (value: string) =>
  175. value
  176. .replace(/[‘’‚‛]/g, "'")
  177. .replace(/[“”„‟]/g, '"')
  178. .replace(/[‐‑‒–—―]/g, "-")
  179. .replace(/…/g, "...")
  180. .replace(/ /g, " ")
  181. const splitBom = (text: string) =>
  182. text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
  183. const stripHeredoc = (input: string) =>
  184. input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input