Files
omp-edit/lib/hashline.js
T

273 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// 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
}