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

+146
View File
@@ -0,0 +1,146 @@
// `sloppy` mode: anchored find / replace / insert-before / insert-after (oh-my-pi compatible).
//
// *** Edit File: path [all]
// *** Find
// <exact text> (`…` = capture of omitted text)
// *** Replace | *** Insert Before | *** Insert After
// <text> (Replace `…` re-emits the next capture)
//
// Edits address the original file; failure applies nothing.
import { EditError, jsonQuote } from './text.js'
const HDR = /^\*\*\*\s*(Edit File:|Find|Replace|Insert Before|Insert After|End(?: Patch| of File)?|Begin Patch)\s*(.*)$/
const ELLIPSIS = /…|\.\.\./
export function parseSloppy(input) {
const lines = String(input).replace(/\r\n?/g, '\n').split('\n')
const files = []
let file = null
let edit = null
let section = null
const flush = () => {
if (section && edit) edit[section.kind] = trimBody(section.rows)
section = null
}
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]
const m = HDR.exec(raw.trim())
if (!m) {
if (section) { section.rows.push(raw); continue }
if (raw.trim() === '') continue
throw new EditError(`Line ${i + 1}: text outside a *** Find/Replace/Insert section: ${jsonQuote(raw)}`)
}
const [, kw, rest] = m
if (kw.startsWith('Begin') || kw.startsWith('End')) { flush(); continue }
if (kw === 'Edit File:') {
flush()
let p = rest.trim()
let all = false
if (/\s+all$/.test(p)) { all = true; p = p.replace(/\s+all$/, '') }
if (/^".*"$/.test(p)) { try { p = JSON.parse(p) } catch {} }
if (!p) {
if (!file) throw new EditError(`Line ${i + 1}: bare '*** Edit File:' with no previous file.`)
if (all) file.all = true
continue
}
file = files.find((f) => f.path === p)
if (!file) { file = { path: p, all, edits: [] }; files.push(file) } else if (all) file.all = true
edit = null
continue
}
if (!file) throw new EditError(`Line ${i + 1}: '*** ${kw}' before any '*** Edit File:' header.`)
if (kw === 'Find') {
flush()
edit = { find: null, replace: undefined, before: undefined, after: undefined, all: file.all }
file.edits.push(edit)
section = { kind: 'find', rows: [] }
continue
}
if (!edit) throw new EditError(`Line ${i + 1}: '*** ${kw}' must follow a '*** Find' section.`)
flush()
section = { kind: kw === 'Replace' ? 'replace' : kw === 'Insert Before' ? 'before' : 'after', rows: [] }
}
flush()
if (!files.length) throw new EditError("No '*** Edit File:' sections found.")
for (const f of files) {
if (!f.edits.length) throw new EditError(`${f.path}: no *** Find sections.`)
for (const e of f.edits) {
if (!e.find) throw new EditError(`${f.path}: empty *** Find section.`)
const acts = ['replace', 'before', 'after'].filter((k) => e[k] !== undefined)
if (acts.length !== 1) throw new EditError(`${f.path}: each *** Find needs exactly one of *** Replace / *** Insert Before / *** Insert After (Find: ${jsonQuote(e.find.split('\n')[0])}).`)
}
}
return files
}
function trimBody(rows) {
const r = [...rows]
while (r.length && r.at(-1).trim() === '') r.pop()
while (r.length && r[0].trim() === '' && r.length > 1) r.shift()
return r.join('\n')
}
/** Build a regex for a Find text with `…` captures. */
function findRegex(find) {
const parts = find.split(ELLIPSIS)
if (parts.length === 1) return null
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
let src = ''
parts.forEach((p, i) => {
src += esc(p)
if (i < parts.length - 1) {
// mid-line gap stays on the line; a line-end gap spans lines
const lineEnd = /\n/.test(parts[i + 1]?.slice(0, 1) ?? '') || parts[i + 1] === ''
src += lineEnd ? '([\\s\\S]*?)' : '([^\\n]*?)'
}
})
return new RegExp(src, 'g')
}
/** Apply a file's sloppy edits to text. */
export function applySloppy(text, file) {
const spans = []
for (const e of file.edits) {
const re = findRegex(e.find)
const hits = []
if (re) {
let m
while ((m = re.exec(text))) { hits.push({ start: m.index, end: m.index + m[0].length, caps: m.slice(1) }); if (m[0].length === 0) re.lastIndex++ }
} else {
let i = 0
while ((i = text.indexOf(e.find, i)) !== -1) { hits.push({ start: i, end: i + e.find.length, caps: [] }); i += Math.max(1, e.find.length) }
}
if (!hits.length) throw new EditError(`${file.path}: *** Find text not found:\n${e.find}\nCopy exact text and indentation from a fresh read.`)
if (hits.length > 1 && !e.all) {
const ln = hits.map((h) => text.slice(0, h.start).split('\n').length)
throw new EditError(`${file.path}: *** Find matched ${hits.length} times (lines ${ln.join(', ')}); add parent context to make it unique, or append ' all' to the Edit File header.`)
}
for (const h of hits) {
let replacement
if (e.replace !== undefined) {
if (e.replace === text.slice(h.start, h.end)) throw new EditError(`${file.path}: no change — the Replace text already equals the file text (Find: ${jsonQuote(e.find.split('\n')[0])}).`)
let k = 0
replacement = e.replace.replace(new RegExp(ELLIPSIS.source, 'g'), () => {
if (k >= h.caps.length) throw new EditError(`${file.path}: Replace has more '…' than the Find captured.`)
return h.caps[k++]
})
} else {
// Insert keeps the match; expand to whole lines.
const ls = text.lastIndexOf('\n', h.start - 1) + 1
let le = text.indexOf('\n', h.end - (text[h.end - 1] === '\n' ? 1 : 0))
if (le === -1) le = text.length
const matched = text.slice(ls, le)
const ins = e.before !== undefined ? e.before : e.after
if (!ins.trim()) throw new EditError(`${file.path}: Insert body is empty.`)
replacement = e.before !== undefined ? `${ins}\n${matched}` : `${matched}\n${ins}`
h.start = ls; h.end = le
}
spans.push({ start: h.start, end: h.end, replacement })
}
}
spans.sort((a, b) => a.start - b.start)
for (let i = 1; i < spans.length; i++) if (spans[i].start < spans[i - 1].end) throw new EditError(`${file.path}: two Find anchors overlap; merge them into one edit.`)
let out = ''; let last = 0
for (const s of spans) { out += text.slice(last, s.start) + s.replacement; last = s.end }
return out + text.slice(last)
}