// 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: 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 ])\\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 }