147 lines
6.3 KiB
JavaScript
147 lines
6.3 KiB
JavaScript
// `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)
|
|
}
|