// `sloppy` mode: anchored find / replace / insert-before / insert-after (oh-my-pi compatible). // // *** Edit File: path [all] // *** Find // (`…` = capture of omitted text) // *** Replace | *** Insert Before | *** Insert After // (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) }