// `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 }