Files

139 lines
6.1 KiB
JavaScript

// `replace` mode: string replacement with oh-my-pi style fuzzy whitespace matching.
//
// Match order: exact → line-trimmed (indentation/trailing whitespace) → whitespace-collapsed
// → typographic-normalized (smart quotes, dashes, nbsp) → similarity ≥ 0.95 over a window of
// the same line count. A fuzzy match is reindented: the replacement keeps the file's actual
// indentation delta.
import { EditError, jsonQuote } from './text.js'
const norms = [
{ name: 'trimmed', fn: (l) => l.trim() },
{ name: 'collapsed', fn: (l) => l.trim().replace(/\s+/g, ' ') },
{
name: 'typography',
fn: (l) => l.trim().replace(/\s+/g, ' ')
.replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"')
.replace(/[\u2013\u2014\u2212]/g, '-').replace(/\u00A0/g, ' ').replace(/\u2026/g, '...'),
},
]
/** Count non-overlapping exact occurrences. */
function countExact(hay, needle) {
let c = 0; let i = 0
while ((i = hay.indexOf(needle, i)) !== -1) { c++; i += needle.length }
return c
}
/**
* Find old in text. Returns a list of {start,end} char offsets (end exclusive) + kind.
*/
export function findMatches(text, old, { fuzzy = true, threshold = 0.95 } = {}) {
const exact = []
let i = 0
while ((i = text.indexOf(old, i)) !== -1) { exact.push({ start: i, end: i + old.length }); i += old.length }
if (exact.length || !fuzzy) return { kind: 'exact', matches: exact }
const tLines = text.split('\n')
const oLines = old.replace(/\n$/, '').split('\n')
// Drop leading/trailing blank lines of the needle for line matching.
while (oLines.length > 1 && oLines[0].trim() === '') oLines.shift()
while (oLines.length > 1 && oLines.at(-1).trim() === '') oLines.pop()
const offsets = []
let acc = 0
for (const l of tLines) { offsets.push(acc); acc += l.length + 1 }
const spanOf = (s, len) => ({ start: offsets[s], end: offsets[s + len - 1] + tLines[s + len - 1].length, lineStart: s, lineCount: len })
for (const { name, fn } of norms) {
const want = oLines.map(fn)
const hits = []
for (let s = 0; s + want.length <= tLines.length; s++) {
let ok = true
for (let k = 0; k < want.length; k++) if (fn(tLines[s + k]) !== want[k]) { ok = false; break }
if (ok) { hits.push(spanOf(s, want.length)); s += want.length - 1 }
}
if (hits.length) return { kind: name, matches: hits }
}
// Similarity window.
if (oLines.length >= 1) {
const want = oLines.map((l) => l.trim()).join('\n')
let best = null; let second = 0
for (let s = 0; s + oLines.length <= tLines.length; s++) {
const got = tLines.slice(s, s + oLines.length).map((l) => l.trim()).join('\n')
const sim = similarity(want, got)
if (!best || sim > best.sim) { second = best?.sim ?? 0; best = { sim, s } } else if (sim > second) second = sim
}
if (best && best.sim >= threshold && best.sim - second > 0.01) {
return { kind: `similar(${best.sim.toFixed(3)})`, matches: [spanOf(best.s, oLines.length)] }
}
if (best && best.sim >= 0.6) return { kind: 'none', matches: [], closest: { line: best.s + 1, sim: best.sim, text: tLines.slice(best.s, best.s + oLines.length).join('\n') } }
}
return { kind: 'none', matches: [] }
}
/** Normalized Levenshtein similarity (bounded for speed). */
export function similarity(a, b) {
if (a === b) return 1
const la = a.length; const lb = b.length
if (!la || !lb) return 0
if (Math.abs(la - lb) / Math.max(la, lb) > 0.2) return 1 - Math.abs(la - lb) / Math.max(la, lb)
if (la * lb > 4_000_000) return a.slice(0, 2000) === b.slice(0, 2000) ? 0.9 : 0
let prev = new Uint32Array(lb + 1); let cur = new Uint32Array(lb + 1)
for (let j = 0; j <= lb; j++) prev[j] = j
for (let i = 1; i <= la; i++) {
cur[0] = i
const ca = a.charCodeAt(i - 1)
for (let j = 1; j <= lb; j++) {
const cost = ca === b.charCodeAt(j - 1) ? 0 : 1
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
}
;[prev, cur] = [cur, prev]
}
return 1 - prev[lb] / Math.max(la, lb)
}
/** Shift the replacement's indentation by the delta between needle and actual text. */
function reindent(oldText, actual, replacement) {
const ind = (s) => (s.split('\n').find((l) => l.trim() !== '') ?? '').match(/^[ \t]*/)[0]
const from = ind(oldText); const to = ind(actual)
if (from === to) return replacement
return replacement.split('\n').map((l) => {
if (l.trim() === '') return l
if (l.startsWith(from)) return to + l.slice(from.length)
return l
}).join('\n')
}
/**
* Apply one replacement to text.
* @returns {{text:string, count:number, kind:string}}
*/
export function applyReplace(text, oldString, newString, { all = false, fuzzy = true, path = 'file' } = {}) {
if (!oldString) throw new EditError('old_string must be non-empty.')
if (oldString === newString) throw new EditError('old_string and new_string are identical; nothing to change.')
const res = findMatches(text, oldString, { fuzzy })
if (!res.matches.length) {
let msg = `old_string was not found in ${path}.`
if (res.closest) msg += ` Closest text (${Math.round(res.closest.sim * 100)}% similar) at line ${res.closest.line}:\n${res.closest.text}\nCopy the exact text from a fresh read.`
throw new EditError(msg)
}
if (res.matches.length > 1 && !all) {
const lines = res.matches.map((m) => text.slice(0, m.start).split('\n').length)
throw new EditError(`old_string matched ${res.matches.length} times in ${path} (lines ${lines.join(', ')}); add surrounding context to make it unique or set replace_all.`)
}
let out = ''
let last = 0
for (const m of res.matches) {
const actual = text.slice(m.start, m.end)
// Fuzzy spans cover whole lines without their final newline; drop the needle's trailing
// newline from the replacement to match, then carry the file's indentation over.
const body = oldString.endsWith('\n') ? newString.replace(/\n$/, '') : newString
const repl = res.kind === 'exact' ? newString : reindent(oldString, actual, body)
out += text.slice(last, m.start) + repl
last = m.end
}
out += text.slice(last)
return { text: out, count: res.matches.length, kind: res.kind }
}
export { countExact, jsonQuote }