Files

107 lines
5.2 KiB
JavaScript

// `apply_patch` mode (Codex-compatible):
//
// *** Begin Patch
// *** Add File: path (+ lines)
// *** Delete File: path
// *** Update File: path
// *** Move to: new/path (optional)
// @@ [context header] (repeatable to narrow scope)
// context / -old / +new lines
// *** End of File (optional: hunk anchored at EOF)
// *** End Patch
import { EditError, jsonQuote } from './text.js'
export function parseApplyPatch(input) {
let lines = String(input).replace(/\r\n?/g, '\n').split('\n')
// Accept missing envelope, and a heredoc wrapper like `apply_patch <<'EOF'`.
lines = lines.filter((l, i) => !(i === 0 && /^apply_patch\b/.test(l.trim())) && !/^EOF$/.test(l.trim()))
const begin = lines.findIndex((l) => l.trim() === '*** Begin Patch')
const end = lines.findLastIndex((l) => l.trim() === '*** End Patch')
if (begin >= 0) lines = lines.slice(begin + 1, end > begin ? end : undefined)
const ops = []
let cur = null
let hunk = null
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]
const t = raw.trim()
let m
if ((m = /^\*\*\* Add File:\s*(.+)$/.exec(t))) { cur = { type: 'add', path: m[1].trim(), lines: [] }; ops.push(cur); hunk = null; continue }
if ((m = /^\*\*\* Delete File:\s*(.+)$/.exec(t))) { cur = { type: 'delete', path: m[1].trim() }; ops.push(cur); hunk = null; continue }
if ((m = /^\*\*\* Update File:\s*(.+)$/.exec(t))) { cur = { type: 'update', path: m[1].trim(), move: null, hunks: [] }; ops.push(cur); hunk = null; continue }
if (!cur) { if (t === '') continue; throw new EditError(`Line ${i + 1}: expected '*** Add/Delete/Update File:' before ${jsonQuote(raw)}.`) }
if ((m = /^\*\*\* Move to:\s*(.+)$/.exec(t))) {
if (cur.type !== 'update') throw new EditError(`Line ${i + 1}: '*** Move to:' only follows '*** Update File:'.`)
cur.move = m[1].trim(); continue
}
if (cur.type === 'add') {
if (raw.startsWith('+')) { cur.lines.push(raw.slice(1)); continue }
if (t === '') continue
throw new EditError(`Line ${i + 1}: every line of an added file must start with '+'.`)
}
if (cur.type === 'delete') { if (t === '') continue; throw new EditError(`Line ${i + 1}: '*** Delete File:' takes no body.`) }
// update
if (t === '*** End of File') { if (hunk) hunk.eof = true; continue }
if (raw.startsWith('@@')) {
const header = raw.slice(2).trim()
if (!hunk || hunk.lines.length) { hunk = { headers: [], lines: [], eof: false }; cur.hunks.push(hunk) }
if (header) hunk.headers.push(header)
continue
}
if (!hunk) { hunk = { headers: [], lines: [], eof: false }; cur.hunks.push(hunk) }
if (raw === '') { hunk.lines.push({ op: ' ', text: '' }); continue }
const c = raw[0]
if (c === ' ' || c === '-' || c === '+') { hunk.lines.push({ op: c, text: raw.slice(1) }); continue }
throw new EditError(`Line ${i + 1}: hunk lines must start with ' ', '-' or '+' (got ${jsonQuote(raw)}).`)
}
if (!ops.length) throw new EditError('apply_patch: no file operations found.')
for (const op of ops) if (op.type === 'update' && !op.hunks.length && !op.move) throw new EditError(`${op.path}: '*** Update File:' has no hunks.`)
return ops
}
/** Locate seq in lines starting at from; tries exact, then trimEnd, then trim. */
function seek(lines, seq, from, eof) {
if (!seq.length) return eof ? lines.length : from
const tries = [(s) => s, (s) => s.trimEnd(), (s) => s.trim()]
for (const f of tries) {
const want = seq.map(f)
const start = eof ? lines.length - seq.length : from
for (let i = Math.max(0, start); i + want.length <= lines.length; i++) {
let ok = true
for (let k = 0; k < want.length; k++) if (f(lines[i + k]) !== want[k]) { ok = false; break }
if (ok) return i
if (eof) break
}
}
return -1
}
/** Apply an Update op's hunks to a line array. */
export function applyUpdate(lines, op) {
const out = [...lines]
let cursor = 0
const repls = []
for (const h of op.hunks) {
for (const hdr of h.headers) {
const idx = out.findIndex((l, i) => i >= cursor && l.includes(hdr.trim()))
if (idx === -1) throw new EditError(`${op.path}: @@ context ${jsonQuote(hdr)} not found.`)
cursor = idx + 1
}
const oldSeq = h.lines.filter((l) => l.op !== '+').map((l) => l.text)
const newSeq = h.lines.filter((l) => l.op !== '-').map((l) => l.text)
let at = seek(out, oldSeq, cursor, h.eof)
if (at === -1 && cursor > 0) at = seek(out, oldSeq, 0, h.eof)
if (at === -1) {
throw new EditError(`${op.path}: hunk context not found:\n${oldSeq.slice(0, 6).join('\n')}${oldSeq.length > 6 ? '\n…' : ''}\nRe-read the file and copy context lines exactly.`)
}
repls.push({ at, len: oldSeq.length, rows: newSeq })
cursor = at + oldSeq.length
}
repls.sort((a, b) => a.at - b.at)
for (let i = 1; i < repls.length; i++) if (repls[i].at < repls[i - 1].at + repls[i - 1].len) throw new EditError(`${op.path}: hunks overlap.`)
const regions = []
let shift = 0
for (const r of repls) { regions.push([r.at + 1 + shift, r.at + shift + Math.max(r.rows.length, 1)]); shift += r.rows.length - r.len }
for (const r of [...repls].reverse()) out.splice(r.at, r.len, ...r.rows)
return { lines: out, regions }
}