/** * ParserBackend — the abstraction layer between the tree-sitter parsing * engine and the per-language extractors. * * Design rationale (issue #1551): everything goes through this interface so * that a native `node-tree-sitter` backend (or the external C binary via the * subprocess provider) can slot in later without touching extractors. The WASM * backend (`WasmTreeSitterBackend`) is the default; it costs ~2–3× parse speed * vs native but has no per-platform build toolchain — the class of pain * documented in #1518 and #1538. * * Rule 11 (no module-level caches): every parser instance, grammar cache, and * initialization flag lives on the *engine instance*, never at module scope. * This means two engines in the same process have fully isolated state. */ import { Parser, Language, type Node as TSNode, type Tree as TSTree, type Language as TSLanguage } from "web-tree-sitter"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { statSync } from "node:fs"; import type { CodingGraphLanguage } from "@remnic/core"; /** Re-export tree-sitter types so extractors don't depend on web-tree-sitter directly. */ export type { TSNode, TSTree, TSLanguage }; /** * The opaque interface a backend exposes to extractors. Each method is keyed * per-instance so the engine lifecycle (`dispose()`) can clean everything up. */ export interface ParserBackend { /** Initialize the backend runtime (e.g. load the WASM core). Idempotent per instance. */ init(): Promise; /** Ensure the grammar for `lang` is loaded and a parser is ready. Idempotent per instance. */ ensureLanguage(lang: CodingGraphLanguage): Promise; /** * Parse `content` (a UTF-8 string) using the grammar for `lang`. Returns the * tree-sitter Tree on success, or `null` if the grammar is not loaded / the * parser cannot produce a tree. Node offsets are UTF-16 code-unit offsets; * callers must convert to UTF-8 byte offsets for multibyte content (#1659). */ parse(lang: CodingGraphLanguage, content: string): TSTree | null; /** Return the loaded Language for `lang`, or null if not loaded. */ getLanguage(lang: CodingGraphLanguage): TSLanguage | null; /** Release all parsers, grammars, and the WASM runtime. Safe to call multiple times. */ dispose(): Promise; } // --------------------------------------------------------------------------- // Grammar directory resolution. // // The .wasm grammar files ship inside the package at `grammars/`. Their // location relative to the running module varies by context: // - tsx source run (src/engine/): ../../grammars // - tsup dist (dist/): ../grammars // - published package (dist/): ../grammars // We probe candidates and pick the first that exists. This avoids hard-coding // a depth that breaks under different bundler layouts. // --------------------------------------------------------------------------- function resolveGrammarDir(): string { const here = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ path.join(here, "..", "grammars"), // dist/ → ../grammars path.join(here, "..", "..", "grammars"), // src/engine/ → ../../grammars path.join(here, "grammars"), // flat layout fallback ]; for (const candidate of candidates) { try { if (statSync(candidate).isDirectory()) return candidate; } catch { // try next candidate } } throw new Error( `coding-graph: could not locate the grammars/ directory from ${here}. ` + `Expected one of: ${candidates.join(", ")}`, ); } /** * web-tree-sitter 0.25 WASM backend. * * Per rule 11, all mutable state (init flag, parser instance, grammar cache) * lives on the instance — never at module scope. The backend is constructed by * `createCodingGraphEngine` and disposed alongside the engine. */ export class WasmTreeSitterBackend implements ParserBackend { private initialized = false; private initializing: Promise | null = null; private parser: Parser | null = null; private readonly languages = new Map(); private readonly loadingLanguages = new Map>(); private grammarDir = ""; private grammarDirResolved = false; private readonly grammarDirHint: string | null; constructor(grammarDir?: string) { // Defer resolveGrammarDir() to first use so that a missing grammars/ // directory surfaces as a per-file parse_failed (rule 44) rather than // a constructor exception that bricks createCodingGraphEngine. this.grammarDirHint = grammarDir ?? null; } private getGrammarDir(): string { if (this.grammarDirResolved) return this.grammarDir; this.grammarDir = this.grammarDirHint ?? resolveGrammarDir(); this.grammarDirResolved = true; return this.grammarDir; } async init(): Promise { if (this.initialized) return; if (this.initializing) return this.initializing; this.initializing = (async () => { try { await Parser.init(); this.parser = new Parser(); this.initialized = true; } finally { // Clear the in-flight promise on BOTH success and failure. Without // this, a transient Parser.init() rejection leaves the rejected // promise cached in `initializing`, so every subsequent call // re-awaits the same failure instead of retrying. this.initializing = null; } })(); return this.initializing; } async ensureLanguage(lang: CodingGraphLanguage): Promise { await this.init(); if (this.languages.has(lang)) return; if (this.loadingLanguages.has(lang)) return this.loadingLanguages.get(lang)!; const p = (async () => { try { const wasmPath = path.join(this.getGrammarDir(), grammarFileName(lang)); const language = await Language.load(wasmPath); this.languages.set(lang, language); } finally { // Drop the in-flight promise on BOTH success and failure. Without // this, a transient Language.load() rejection leaves the rejected // promise cached in `loadingLanguages`, permanently bricking that // language until dispose() — the next call would re-await the same // failure instead of retrying the load. this.loadingLanguages.delete(lang); } })(); this.loadingLanguages.set(lang, p); return p; } parse(lang: CodingGraphLanguage, content: string): TSTree | null { if (!this.parser) return null; const language = this.languages.get(lang); if (!language) return null; this.parser.setLanguage(language); return this.parser.parse(content); } /** Return the loaded Language object for `lang`, or null if not loaded. */ getLanguage(lang: CodingGraphLanguage): Language | null { return this.languages.get(lang) ?? null; } async dispose(): Promise { if (this.parser) { try { this.parser.delete(); } catch { // already deleted — ignore } this.parser = null; } this.languages.clear(); this.loadingLanguages.clear(); this.initialized = false; this.initializing = null; this.grammarDirResolved = false; this.grammarDir = ""; } } /** * Map a tier-1 language to its .wasm grammar filename. The names match the * files shipped by `tree-sitter-wasms` (0.1.13) and the grammars/ directory. * C# uses `c_sharp` because the upstream grammar names C# with an underscore. */ export function grammarFileName(lang: CodingGraphLanguage): string { const map: Record = { typescript: "tree-sitter-typescript.wasm", tsx: "tree-sitter-tsx.wasm", javascript: "tree-sitter-javascript.wasm", python: "tree-sitter-python.wasm", go: "tree-sitter-go.wasm", rust: "tree-sitter-rust.wasm", java: "tree-sitter-java.wasm", c: "tree-sitter-c.wasm", cpp: "tree-sitter-cpp.wasm", csharp: "tree-sitter-c_sharp.wasm", ruby: "tree-sitter-ruby.wasm", php: "tree-sitter-php.wasm", kotlin: "tree-sitter-kotlin.wasm", swift: "tree-sitter-swift.wasm", bash: "tree-sitter-bash.wasm", }; return map[lang]; }