diff --git a/src/anchorer.ts b/src/anchorer.ts new file mode 100644 index 0000000..e8f9162 --- /dev/null +++ b/src/anchorer.ts @@ -0,0 +1,113 @@ +/** + * 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) }; +} diff --git a/test/anchorer.test.ts b/test/anchorer.test.ts new file mode 100644 index 0000000..852ddff --- /dev/null +++ b/test/anchorer.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer"; + +const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n"; + +describe("buildFingerprint", () => { + it("captures the exact text, bounded context, and 0-based lineHint", () => { + const start = DOC.indexOf("needle here"); + const range: OffsetRange = { start, end: start + "needle".length }; + const fp = buildFingerprint(DOC, range); + expect(fp.text).toBe("needle"); + expect(fp.lineHint).toBe(1); + expect(fp.before.endsWith("line1 ")).toBe(true); + expect(fp.after.startsWith(" here")).toBe(true); + expect(fp.before.length).toBeLessThanOrEqual(120); + expect(fp.after.length).toBeLessThanOrEqual(120); + }); +}); + +describe("resolve", () => { + it("exact-unique: returns the single occurrence's range", () => { + const fp = { text: "line2", before: "", after: "", lineHint: 0 }; + const start = DOC.indexOf("line2"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 }); + }); + + it("context-disambiguated: picks the occurrence whose before/after match", () => { + const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 }; + const start = DOC.indexOf("needle here"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 }); + }); + + it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => { + // both occurrences of 'needle', no usable context, hint points at line 3 + const fp = { text: "needle", before: "", after: "", lineHint: 3 }; + const start = DOC.indexOf("needle again"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 }); + }); + + it("orphaned: returns 'orphaned' when the text is gone", () => { + const fp = { text: "absent-text", before: "", after: "", lineHint: 0 }; + expect(resolve(DOC, fp)).toBe("orphaned"); + }); + + it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => { + const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint + const fp = { text: "needle", before: "", after: "", lineHint: 1 }; + expect(resolve(doc, fp)).toBe("orphaned"); + }); +}); + +describe("shift", () => { + it("edit entirely before the range shifts both endpoints by the delta", () => { + const r: OffsetRange = { start: 10, end: 15 }; + // insert 2 chars at offset 0 (replace [0,0) with len 2) + expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 }); + }); + + it("edit entirely after the range leaves it unchanged", () => { + const r: OffsetRange = { start: 2, end: 5 }; + expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 }); + }); + + it("edit overlapping the range clamps the touched endpoints to the edit start", () => { + const r: OffsetRange = { start: 5, end: 10 }; + // replace [3,7) with 1 char (delta = -3) + expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 }); + }); +});