/** * Structural snapping and the two-tier AST fingerprint. Operates on tree-sitter * nodes; deterministic and language-universal except for the value map. */ import type { Node } from "web-tree-sitter"; import { collapseWhitespace } from "../algo/normalize.ts"; import type { Region } from "../core/model.ts"; import { valueClass } from "./value-map.ts"; /** Content-literal kinds: some grammars hide a literal body. */ const CONTENT_LITERAL = new Set([ "string", "string_literal", "interpreted_string_literal", "raw_string_literal", "char_literal", "rune_literal", "number", "integer", "float", "integer_literal", "float_literal", "int_literal", "imaginary_literal", ]); // The US control char (\x1f): a delimiter between serialized nodes so adjacent // kinds/tokens cannot concatenate into a colliding stream. const SEP = "\u001f"; function xx(s: string): string { return Bun.hash.xxHash64(s).toString(16).padStart(16, "0"); } /** * Snap a region to the smallest enclosing named node. Leading and trailing * whitespace is trimmed first so the chosen node is invariant to re-indentation. */ export function snapNamedNode( root: Node, text: string, region: Region, ): Node | null { let ts = Math.max(0, region.start); let te = Math.min(text.length, region.end); const span = text.slice(ts, te); const lead = span.length - span.replace(/^\s+/, "").length; const trail = span.length - span.replace(/\s+$/, "").length; ts += lead; te -= trail; if (ts >= te) te = ts + 1; let node = root.descendantForIndex(ts, Math.max(ts, te - 1)); while (node && !node.isNamed) node = node.parent; return node ?? root; } export interface AstFingerprint { nodeType: string; structuralHash: string; semanticHash: string; } /** * Two-tier fingerprint: pre-order DFS over all children, source order. * - structural: the `type` of every node (invariant under renames and literals). * - semantic: leaf → `type:text`; internal → `type`; content literals add * `=`. */ export function fingerprintNode(node: Node): AstFingerprint { const struct: string[] = []; const sem: string[] = []; const visit = (n: Node): void => { struct.push(n.type); const leaf = n.childCount === 0; sem.push(leaf ? `${n.type}:${n.text}` : n.type); if (CONTENT_LITERAL.has(n.type)) sem.push(`=${collapseWhitespace(n.text)}`); for (let i = 0; i < n.childCount; i++) { const c = n.child(i); if (c) visit(c); } }; visit(node); return { nodeType: node.type, structuralHash: xx(struct.join(SEP)), semanticHash: xx(sem.join(SEP)), }; } /** * Extract the first literal whose byte range lies inside `span`: pre-order DFS * over named children under `node`, take the first matching literal and stop. * Collections strip all whitespace; scalars and strings are whitespace-collapsed. * A literal outside the quoted span never counts, so quoting a signature does * not store a value from the body. */ export function extractValueFrom( node: Node, language: string, span: Region, targetKind?: string, ): { nodeKind: string; value: string } | null { let found: { nodeKind: string; value: string } | null = null; const visit = (n: Node): void => { if (found) return; if (n.endIndex <= span.start || n.startIndex >= span.end) return; const cls = valueClass(language, n.type); if ( cls && n.startIndex >= span.start && n.endIndex <= span.end && (targetKind === undefined || n.type === targetKind) ) { const raw = n.text; const value = cls === "collection" ? raw.replace(/\s+/g, "") : collapseWhitespace(raw); found = { nodeKind: n.type, value }; return; } for (let i = 0; i < n.namedChildCount; i++) { const c = n.namedChild(i); if (c) visit(c); if (found) return; } }; visit(node); return found; }