Initial commit: omp-edit DSH plugin (oh-my-pi port) with docs and tests

This commit is contained in:
Agent committed 2026-10-10 19:15:23 +00:00
commit 71953ffb8f
19 files changed
+2412

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
// 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
}