273 lines
13 KiB
JavaScript
273 lines
13 KiB
JavaScript
// Hashline patch language (oh-my-pi compatible).
|
||
//
|
||
// [PATH#TAG] section header; TAG = 4-hex snapshot of the file from read/grep/edit
|
||
// PUT N.=M: +rows replace original lines N..M
|
||
// PUT N*: +rows replace the syntactic block opening at N
|
||
// PUT <N: / PUT >N: insert before / after line N (<1 = head, >$ = tail)
|
||
// PUT >N*: insert after the block opening at N
|
||
// CUT N.=M [@name] delete + capture (anonymous register when unnamed)
|
||
// CUT N* [@name] delete + capture a block
|
||
// PUT <N [@name] paste register at a gap (no body)
|
||
// PUT N.=M @name paste named register over a range/block
|
||
// REM delete the file
|
||
// MV DEST move after edits
|
||
//
|
||
// All line numbers refer to the tagged snapshot, never shifted by earlier hunks.
|
||
import { resolveBlock } from './block.js'
|
||
import { EditError, HEADER_RE, jsonQuote } from './text.js'
|
||
|
||
const LINE = '([1-9]\\d*)'
|
||
const REG = '(?:\\s+@([A-Za-z0-9_-]+))?'
|
||
const RE = {
|
||
putRange: new RegExp(`^PUT\\s+${LINE}\\s*\\.=\\s*${LINE}\\s*(:)?${REG}\\s*$`),
|
||
putBlock: new RegExp(`^PUT\\s+${LINE}\\*\\s*(:)?${REG}\\s*$`),
|
||
putGap: new RegExp(`^PUT\\s+([<>])\\s*(${'[1-9]\\d*'}|\\$)(\\*)?\\s*(:)?${REG}\\s*$`),
|
||
cutRange: new RegExp(`^CUT\\s+${LINE}\\s*\\.=\\s*${LINE}${REG}\\s*$`),
|
||
cutBlock: new RegExp(`^CUT\\s+${LINE}\\*${REG}\\s*$`),
|
||
rem: /^REM\s*$/,
|
||
mv: /^MV\s+(.+?)\s*$/,
|
||
// Tolerated spellings the models often emit: `PUT N-M:`, `PUT N:` (single line), `PUT N..M:`.
|
||
putLoose: new RegExp(`^PUT\\s+${LINE}(?:\\s*(?:-|\\.\\.|\\.=|–)\\s*${LINE})?\\s*(:)${REG}\\s*$`),
|
||
}
|
||
|
||
/**
|
||
* Parse a hashline payload into file sections.
|
||
* @returns {{sections: Array<{path:string, tag:string, ops:object[], line:number}>, warnings:string[]}}
|
||
*/
|
||
export function parseHashline(input) {
|
||
const warnings = []
|
||
let lines = String(input).replace(/\r\n?/g, '\n').split('\n')
|
||
// Optional envelope.
|
||
lines = lines.filter((l) => !/^\*\*\*\s*(Begin|End) Patch\s*$/.test(l.trim()))
|
||
// Strip a surrounding markdown fence the model may have added.
|
||
if (lines.length && /^```/.test(lines[0].trim())) {
|
||
lines.shift()
|
||
const close = lines.findLastIndex((l) => /^```\s*$/.test(l.trim()))
|
||
if (close >= 0) lines.splice(close, 1)
|
||
}
|
||
const sections = []
|
||
let cur = null
|
||
let op = null
|
||
const endOp = () => { op = null }
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const raw = lines[i]
|
||
const lineNo = i + 1
|
||
const h = HEADER_RE.exec(raw.trim())
|
||
if (h) {
|
||
endOp()
|
||
cur = { path: unquote(h[1].trim()), tag: h[2].toUpperCase(), ops: [], line: lineNo }
|
||
sections.push(cur)
|
||
continue
|
||
}
|
||
if (op && op.body && raw.startsWith('+')) { op.body.push(raw.slice(1)); continue }
|
||
const t = raw.trim()
|
||
if (t === '') { if (op && op.body) { /* blank between rows: treat as end */ endOp() } continue }
|
||
if (!cur) {
|
||
if (/^\[.+\]$/.test(t)) throw new EditError(`Line ${lineNo}: section header ${jsonQuote(t)} is missing the 4-hex snapshot tag. Copy the [PATH#TAG] header from the latest read/grep/edit output.`)
|
||
throw new EditError(`Line ${lineNo}: expected a [PATH#TAG] section header before ${jsonQuote(t)}.`)
|
||
}
|
||
if (op && op.body && (t.startsWith('-') || /^ /.test(raw))) {
|
||
throw new EditError(`Line ${lineNo}: body rows must start with '+' (got ${jsonQuote(raw)}). Hashline bodies are final content, never diff '-'/context lines; write literal '-x' as '+-x'.`)
|
||
}
|
||
op = parseOp(t, lineNo, warnings)
|
||
cur.ops.push(op)
|
||
if (!op.takesBody) op = null
|
||
}
|
||
for (const s of sections) {
|
||
for (const o of s.ops) {
|
||
if (o.takesBody && o.body.length === 0) {
|
||
throw new EditError(`${s.path}: ${o.src} has no '+' body rows. Use CUT to delete lines; a lone '+' writes a blank line.`)
|
||
}
|
||
}
|
||
if (!s.ops.length) throw new EditError(`${s.path}: section has no operations.`)
|
||
}
|
||
if (!sections.length) throw new EditError('No [PATH#TAG] sections found in the patch.')
|
||
return { sections, warnings }
|
||
}
|
||
|
||
function unquote(p) {
|
||
if (/^".*"$/.test(p)) { try { return JSON.parse(p) } catch { return p.slice(1, -1) } }
|
||
return p
|
||
}
|
||
|
||
function parseOp(t, lineNo, warnings) {
|
||
let m
|
||
if ((m = RE.putRange.exec(t))) {
|
||
const [, a, b, colon, reg] = m
|
||
if (!colon && !reg) throw new EditError(`Line ${lineNo}: ${jsonQuote(t)} needs ':' and '+' rows, or '@name' to paste a register.`)
|
||
if (!colon && reg) return { kind: 'paste', src: t, target: { span: { start: +a, end: +b } }, reg }
|
||
return { kind: 'replace', src: t, start: +a, end: +b, takesBody: true, body: [] }
|
||
}
|
||
if ((m = RE.putBlock.exec(t))) {
|
||
const [, a, colon, reg] = m
|
||
if (!colon && !reg) throw new EditError(`Line ${lineNo}: ${jsonQuote(t)} needs ':' and '+' rows, or '@name' to paste a register.`)
|
||
if (!colon && reg) return { kind: 'paste', src: t, target: { block: +a }, reg }
|
||
return { kind: 'replaceBlock', src: t, line: +a, takesBody: true, body: [] }
|
||
}
|
||
if ((m = RE.putGap.exec(t))) {
|
||
const [, side, at, star, colon, reg] = m
|
||
const anchor = at === '$' ? '$' : +at
|
||
if (side === '<' && anchor === '$') throw new EditError(`Line ${lineNo}: use 'PUT >$' to append at the end of the file.`)
|
||
if (colon) return { kind: 'insert', src: t, side, anchor, block: !!star, takesBody: true, body: [] }
|
||
if (star) throw new EditError(`Line ${lineNo}: register paste after a block is not supported; use PUT >M @name with the closer line.`)
|
||
return { kind: 'paste', src: t, target: { gap: { side, anchor } }, reg: reg ?? null }
|
||
}
|
||
if ((m = RE.cutRange.exec(t))) return { kind: 'cut', src: t, start: +m[1], end: +m[2], reg: m[3] ?? null }
|
||
if ((m = RE.cutBlock.exec(t))) return { kind: 'cutBlock', src: t, line: +m[1], reg: m[2] ?? null }
|
||
if (RE.rem.test(t)) return { kind: 'rem', src: t }
|
||
if ((m = RE.mv.exec(t))) return { kind: 'mv', src: t, dest: unquote(m[1]) }
|
||
if ((m = RE.putLoose.exec(t))) {
|
||
const a = +m[1]; const b = m[2] ? +m[2] : a
|
||
warnings.push(`Read ${jsonQuote(t)} as "PUT ${a}.=${b}:" (canonical range syntax is N.=M).`)
|
||
return { kind: 'replace', src: t, start: a, end: b, takesBody: true, body: [] }
|
||
}
|
||
if (/^[-+]/.test(t)) throw new EditError(`Line ${lineNo}: ${jsonQuote(t)} is a body row without a preceding 'PUT …:' header.`)
|
||
throw new EditError(`Line ${lineNo}: unrecognized operation ${jsonQuote(t)}. Expected PUT/CUT/REM/MV (see the edit tool description).`)
|
||
}
|
||
|
||
/** Line numbers an op depends on (for stale-tag recovery and range validation). */
|
||
export function opAnchors(op) {
|
||
switch (op.kind) {
|
||
case 'replace': case 'cut': { const a = []; for (let n = op.start; n <= op.end; n++) a.push(n); return a }
|
||
case 'replaceBlock': case 'cutBlock': return [op.line]
|
||
case 'insert': return op.anchor === '$' ? [] : [op.anchor]
|
||
case 'paste':
|
||
if (op.target.span) { const a = []; for (let n = op.target.span.start; n <= op.target.span.end; n++) a.push(n); return a }
|
||
if (op.target.block) return [op.target.block]
|
||
return op.target.gap.anchor === '$' ? [] : [op.target.gap.anchor]
|
||
default: return []
|
||
}
|
||
}
|
||
|
||
/** Return a copy of op with every line number passed through map(n). */
|
||
export function remapOp(op, map) {
|
||
const o = structuredClone(op)
|
||
const m = (n) => (n === '$' ? '$' : map(n))
|
||
switch (o.kind) {
|
||
case 'replace': case 'cut': o.start = m(o.start); o.end = m(o.end); break
|
||
case 'replaceBlock': case 'cutBlock': o.line = m(o.line); break
|
||
case 'insert': o.anchor = m(o.anchor); break
|
||
case 'paste':
|
||
if (o.target.span) { o.target.span.start = m(o.target.span.start); o.target.span.end = m(o.target.span.end) }
|
||
else if (o.target.block) o.target.block = m(o.target.block)
|
||
else o.target.gap.anchor = m(o.target.gap.anchor)
|
||
break
|
||
}
|
||
return o
|
||
}
|
||
|
||
/**
|
||
* Apply one section's ops to `lines` (the snapshot the tag names).
|
||
* Edits are resolved against original numbering, then applied bottom-up.
|
||
* @param {object} ctx { path, lines, registers: Map, anon: {value}, warnings }
|
||
* @returns {{lines:string[], regions:Array<[number,number]>, rem:boolean, mv:string|null}}
|
||
*/
|
||
export function applySection(section, { path, lines, registers, anon, warnings }) {
|
||
const n = lines.length
|
||
const checkLine = (x, what) => {
|
||
if (x === '$') return
|
||
if (!Number.isInteger(x) || x < 1 || x > Math.max(n, 1)) {
|
||
throw new EditError(`${path}: ${what} line ${x} is outside the file (it has ${n} line${n === 1 ? '' : 's'}). Line numbers come from the tagged snapshot; re-read if unsure.`)
|
||
}
|
||
}
|
||
const block = (line) => resolveBlock(path, lines, line)
|
||
// Normalize every op to an edit over original numbering:
|
||
// { start, end, rows } replace [start..end] (end = start-1 for a pure insertion before start)
|
||
const edits = []
|
||
let rem = false
|
||
let mv = null
|
||
for (const op of section.ops) {
|
||
switch (op.kind) {
|
||
case 'replace': {
|
||
checkLine(op.start, op.src); checkLine(op.end, op.src)
|
||
if (op.end < op.start) throw new EditError(`${path}: ${op.src} has end before start.`)
|
||
edits.push({ start: op.start, end: op.end, rows: op.body, src: op.src })
|
||
break
|
||
}
|
||
case 'replaceBlock': {
|
||
checkLine(op.line, op.src)
|
||
const b = block(op.line)
|
||
edits.push({ start: b.start, end: b.end, rows: op.body, src: op.src })
|
||
warnings.push(`${op.src} → resolved block ${b.start}-${b.end}.`)
|
||
break
|
||
}
|
||
case 'insert': {
|
||
const at = gapIndex(op.side, op.anchor, op.block, n, checkLine, block, op.src, warnings)
|
||
edits.push({ start: at, end: at - 1, rows: op.body, src: op.src })
|
||
break
|
||
}
|
||
case 'cut': case 'cutBlock': {
|
||
let s, e
|
||
if (op.kind === 'cut') { checkLine(op.start, op.src); checkLine(op.end, op.src); s = op.start; e = op.end }
|
||
else { checkLine(op.line, op.src); const b = block(op.line); s = b.start; e = b.end; warnings.push(`${op.src} → resolved block ${b.start}-${b.end}.`) }
|
||
if (e < s) throw new EditError(`${path}: ${op.src} has end before start.`)
|
||
const captured = lines.slice(s - 1, e)
|
||
if (op.reg) registers.set(op.reg, captured)
|
||
else anon.value = captured
|
||
edits.push({ start: s, end: e, rows: [], src: op.src })
|
||
break
|
||
}
|
||
case 'paste': {
|
||
const rows = op.reg ? registers.get(op.reg) : anon.value
|
||
if (!rows) {
|
||
throw new EditError(op.reg
|
||
? `${path}: register @${op.reg} is empty. CUT into it first (earlier in this call or a previous call).`
|
||
: `${path}: the anonymous register is empty; CUT before pasting in the same call, or use a named register.`)
|
||
}
|
||
if (op.target.gap) {
|
||
const at = gapIndex(op.target.gap.side, op.target.gap.anchor, false, n, checkLine, block, op.src, warnings)
|
||
edits.push({ start: at, end: at - 1, rows: [...rows], src: op.src })
|
||
} else if (op.target.span) {
|
||
const { start, end } = op.target.span
|
||
checkLine(start, op.src); checkLine(end, op.src)
|
||
edits.push({ start, end, rows: [...rows], src: op.src })
|
||
} else {
|
||
checkLine(op.target.block, op.src)
|
||
const b = block(op.target.block)
|
||
edits.push({ start: b.start, end: b.end, rows: [...rows], src: op.src })
|
||
}
|
||
break
|
||
}
|
||
case 'rem': rem = true; break
|
||
case 'mv': mv = op.dest; break
|
||
}
|
||
}
|
||
if (rem && edits.length) throw new EditError(`${path}: REM cannot be combined with other edits in the same section.`)
|
||
// Overlap check over original numbering (pure insertions may share a gap; they keep order).
|
||
const sorted = edits.map((e, i) => ({ ...e, i })).sort((a, b) => a.start - b.start || a.i - b.i)
|
||
for (let k = 1; k < sorted.length; k++) {
|
||
const p = sorted[k - 1]; const c = sorted[k]
|
||
const pIns = p.end < p.start; const cIns = c.end < c.start
|
||
if (!pIns && !cIns && c.start <= p.end) throw new EditError(`${path}: ${p.src} and ${c.src} overlap (lines ${c.start}-${Math.min(p.end, c.end)}). Merge them into one range.`)
|
||
if (!pIns && cIns && c.start > p.start && c.start <= p.end) throw new EditError(`${path}: ${c.src} inserts inside the range replaced by ${p.src}.`)
|
||
}
|
||
// Apply bottom-up so earlier numbering stays valid.
|
||
const out = [...lines]
|
||
const applied = []
|
||
for (const e of [...sorted].sort((a, b) => b.start - a.start || b.i - a.i)) {
|
||
out.splice(e.start - 1, e.end - e.start + 1, ...e.rows)
|
||
applied.push(e)
|
||
}
|
||
// Regions in new numbering for the preview.
|
||
const regions = []
|
||
for (const e of sorted) {
|
||
let shift = 0
|
||
for (const o of sorted) if (o.start < e.start || (o.start === e.start && o.i < e.i)) shift += o.rows.length - (o.end - o.start + 1)
|
||
const s = e.start + shift
|
||
regions.push([Math.max(1, s), Math.max(1, s + Math.max(e.rows.length, 1) - 1)])
|
||
}
|
||
return { lines: out, regions, rem, mv }
|
||
}
|
||
|
||
function gapIndex(side, anchor, isBlock, n, checkLine, block, src, warnings) {
|
||
if (anchor === '$') return n + 1
|
||
checkLine(anchor, src)
|
||
if (side === '<') return anchor
|
||
if (isBlock) {
|
||
const b = block(anchor)
|
||
warnings.push(`${src} → inserting after block ${b.start}-${b.end}.`)
|
||
return b.end + 1
|
||
}
|
||
return anchor + 1
|
||
}
|