107 lines
5.2 KiB
JavaScript
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 }
|
|
}
|