/** * Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source * of truth for an anchor's location (INV-3); live OFFSET ranges are a within- * session optimization. The resolution ladder is: * exact-unique → context-disambiguated → lineHint tiebreak → orphaned. * It NEVER guesses on an unbreakable tie — it orphans instead (INV-1). * * vscode-free: operates on plain strings and character-offset ranges, so it is * unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range. */ import type { Fingerprint } from "./model"; export interface OffsetRange { start: number; end: number; } /** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */ export interface TextEdit { start: number; end: number; newLength: number; } const MAX_CTX_CHARS = 120; const MAX_CTX_LINES = 3; function lineNumberAt(doc: string, offset: number): number { let line = 0; const bound = Math.min(offset, doc.length); for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++; return line; } function leadingContext(doc: string, start: number): string { let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start); const lines = s.split("\n"); if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n"); return s; } function trailingContext(doc: string, end: number): string { let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS)); const lines = s.split("\n"); if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n"); return s; } export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint { return { text: docText.slice(range.start, range.end), before: leadingContext(docText, range.start), after: trailingContext(docText, range.end), lineHint: lineNumberAt(docText, range.start), }; } function allIndexesOf(hay: string, needle: string): number[] { if (needle.length === 0) return []; const out: number[] = []; let from = 0; let idx = hay.indexOf(needle, from); while (idx !== -1) { out.push(idx); from = idx + 1; idx = hay.indexOf(needle, from); } return out; } function contextMatches(doc: string, i: number, fp: Fingerprint): boolean { const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before; const afterStart = i + fp.text.length; const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after; return beforeOk && afterOk; } const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length }); /** * Re-resolve a fingerprint against current text. Returns the resolved range or * "orphaned" when no confident, unique match exists (INV-1). */ export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" { const occ = allIndexesOf(docText, fp.text); if (occ.length === 0) return "orphaned"; if (occ.length === 1) return rangeAt(occ[0], fp.text); // Multiple exact matches: try context disambiguation. const ctx = occ.filter((i) => contextMatches(docText, i, fp)); if (ctx.length === 1) return rangeAt(ctx[0], fp.text); // Still ambiguous: break ties by proximity to lineHint within the best pool. const pool = ctx.length > 0 ? ctx : occ; const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) })); const min = Math.min(...dists.map((x) => x.d)); const closest = dists.filter((x) => x.d === min); if (closest.length === 1) return rangeAt(closest[0].i, fp.text); // Unbreakable tie — refuse to guess. return "orphaned"; } /** Maintain a live range across an in-session edit (spec §6.4 `shift`). */ export function shift(range: OffsetRange, edit: TextEdit): OffsetRange { const delta = edit.newLength - (edit.end - edit.start); const point = (p: number): number => { if (p <= edit.start) return p; if (p >= edit.end) return p + delta; return edit.start; // inside the replaced span — clamp to its start }; return { start: point(range.start), end: point(range.end) }; }