opencode/packages/util/src/patch.ts
2026-07-21 21:13:21 +05:30

236 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export * as Patch from "./patch"
import { Result, Schema } from "effect"
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
boundary: Schema.Literals(["first", "last"]),
}) {
override get message() {
return `The ${this.boundary} line of the patch must be '${this.boundary === "first" ? "*** Begin Patch" : "*** End Patch"}'`
}
}
export class InvalidHunkError extends Schema.TaggedErrorClass<InvalidHunkError>()("Patch.InvalidHunkError", {
line: Schema.String,
lineNumber: Schema.Number,
}) {
override get message() {
return `Invalid hunk at line ${this.lineNumber}: '${this.line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`
}
}
export type ParseError = BoundaryError | InvalidHunkError
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
| {
readonly type: "update"
readonly path: string
readonly movePath?: string
readonly chunks: ReadonlyArray<UpdateFileChunk>
}
export interface UpdateFileChunk {
readonly oldLines: ReadonlyArray<string>
readonly newLines: ReadonlyArray<string>
readonly changeContext?: string
readonly endOfFile?: boolean
}
export interface FileUpdate {
readonly content: string
readonly bom: boolean
}
export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, ParseError> {
const lines = stripHeredoc(patchText.trim())
.split("\n")
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" }))
if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" }))
const hunks: Hunk[] = []
let index = begin + 1
while (index < end) {
const line = lines[index]!
const header = line.trim()
if (header.startsWith("*** Add File:")) {
const path = header.slice("*** Add File:".length).trim()
if (!path) {
index++
continue
}
const parsed = parseAdd(lines, index + 1, end)
hunks.push({ type: "add", path, contents: parsed.content })
index = parsed.next
continue
}
if (header.startsWith("*** Delete File:")) {
const path = header.slice("*** Delete File:".length).trim()
if (!path) {
index++
continue
}
hunks.push({ type: "delete", path })
index++
continue
}
if (header.startsWith("*** Update File:")) {
const path = header.slice("*** Update File:".length).trim()
if (!path) {
index++
continue
}
let next = index + 1
let movePath: string | undefined
if (lines[next]?.startsWith("*** Move to:")) {
movePath = lines[next]!.slice("*** Move to:".length).trim()
next++
}
const parsed = parseUpdate(lines, next, end)
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
index = parsed.next
continue
}
index++
}
if (hunks.length === 0) {
const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "")
if (invalid !== -1) {
return Result.fail(new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }))
}
}
return Result.succeed(hunks)
}
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
const source = splitBom(original)
const lines = source.text.split("\n")
if (lines.at(-1) === "") lines.pop()
const replacements = computeReplacements(lines, path, chunks)
const updated = [...lines]
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
if (updated.at(-1) !== "") updated.push("")
const next = splitBom(updated.join("\n"))
return { content: next.text, bom: source.bom || next.bom }
}
export function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function parseAdd(lines: ReadonlyArray<string>, start: number, end: number) {
const content: string[] = []
let index = start
while (index < end && !lines[index]!.startsWith("***")) {
if (lines[index]!.startsWith("+")) content.push(lines[index]!.slice(1))
index++
}
return { content: content.join("\n"), next: index }
}
function parseUpdate(lines: ReadonlyArray<string>, start: number, end: number) {
const chunks: UpdateFileChunk[] = []
let index = start
while (index < end && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("@@")) {
index++
continue
}
const changeContext = lines[index]!.slice(2).trim() || undefined
const oldLines: string[] = []
const newLines: string[] = []
let endOfFile = false
index++
while (index < end && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) {
const line = lines[index]!
if (line.startsWith(" ")) {
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
else if (line.startsWith("+")) newLines.push(line.slice(1))
index++
}
if (lines[index]?.trim() === "*** End of File") {
endOfFile = true
index++
}
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
}
return { chunks, next: index }
}
function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
let lineIndex = 0
for (const chunk of chunks) {
if (chunk.changeContext) {
const context = seek(lines, [chunk.changeContext], lineIndex)
if (context === -1) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`)
lineIndex = context + 1
}
if (chunk.oldLines.length === 0) {
replacements.push([lines.length, 0, chunk.newLines])
continue
}
let oldLines = chunk.oldLines
let newLines = chunk.newLines
let found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
if (found === -1 && oldLines.at(-1) === "") {
oldLines = oldLines.slice(0, -1)
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
}
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
replacements.push([found, oldLines.length, newLines])
lineIndex = found + oldLines.length
}
return replacements.toSorted((left, right) => left[0] - right[0])
}
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
if (pattern.length === 0) return -1
if (eof) {
const offset = lines.length - pattern.length
if (offset < start) return -1
for (const compare of [exact, rstrip, trim, normalized]) {
if (matches(lines, pattern, offset, compare)) return offset
}
return -1
}
for (const compare of [exact, rstrip, trim, normalized]) {
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
if (matches(lines, pattern, offset, compare)) return offset
}
}
return -1
}
function matches(
lines: ReadonlyArray<string>,
pattern: ReadonlyArray<string>,
offset: number,
compare: (left: string, right: string) => boolean,
) {
return pattern.every((line, index) => compare(lines[offset + index]!, line))
}
const exact = (left: string, right: string) => left === right
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
const trim = (left: string, right: string) => left.trim() === right.trim()
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
const normalize = (value: string) =>
value
.replace(/[]/g, "'")
.replace(/[“”„‟]/g, '"')
.replace(/[‐‑‒–—―]/g, "-")
.replace(/…/g, "...")
.replace(/ /g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input