// `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 } }