Skip to main content

grove_core/
engine.rs

1//! The structural engine: parse + tag extraction + syntax check, over grammars
2//! loaded from the registry as wasm.
3//!
4//! Tags are extracted by running the grammar's `tags.scm` through the Query
5//! engine (interpreting `@definition.*` / `@reference.*` / `@name` captures),
6//! because `tree-sitter-tags` cannot drive a wasm-loaded language. The same path
7//! serves every language, static or wasm.
8
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::sync::OnceLock;
12
13use anyhow::{anyhow, Context, Result};
14use serde::Serialize;
15use streaming_iterator::StreamingIterator;
16use tree_sitter::{Language, Node, Parser, Query, QueryCursor, WasmStore};
17
18use crate::registry::{Grammar, Profile};
19
20/// A definition or reference extracted from a file.
21#[derive(Debug, Serialize)]
22pub struct Symbol {
23    /// Stable handle: `<lang>:<relpath>#<name>@<line>` (line is 1-based). Survives across turns.
24    pub id: String,
25    pub name: String,
26    /// e.g. `function`, `method`, `class`, `call` — from the grammar's tag query.
27    pub kind: String,
28    pub is_definition: bool,
29    pub file: String,
30    /// 1-based line and column of the name — the editor / `grep -n` convention,
31    /// so a citation printed as "line N" lands on the right line. (tree-sitter's
32    /// own `Point` is 0-based; we normalize here. The byte range below, not these,
33    /// is what `source` slices.)
34    pub line: usize,
35    pub col: usize,
36    /// Byte range of the whole symbol (what `source` slices).
37    pub start_byte: usize,
38    pub end_byte: usize,
39    /// The trimmed source line containing the name — a compact signature.
40    pub signature: String,
41    /// The owning container — the `impl` type, trait, class, or module.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub parent: Option<String>,
44}
45
46/// A syntactic defect found by `check`.
47#[derive(Debug, Serialize)]
48pub struct Defect {
49    pub kind: &'static str,
50    /// 1-based line and column — same editor / `grep -n` convention as `Symbol`.
51    pub line: usize,
52    pub col: usize,
53    pub start_byte: usize,
54    pub end_byte: usize,
55    pub text: String,
56}
57
58// The per-language node-kind profile is data now — it comes from the grammar's
59// manifest (`registry::Profile`), not a table compiled in here.
60
61// ---- loaded-grammar cache (load each wasm grammar once per process) ----
62
63fn engine() -> &'static tree_sitter::wasmtime::Engine {
64    static E: OnceLock<tree_sitter::wasmtime::Engine> = OnceLock::new();
65    E.get_or_init(tree_sitter::wasmtime::Engine::default)
66}
67
68struct Loaded {
69    parser: Parser,
70    /// Held to anchor the wasm-loaded language's lifetime alongside its store.
71    #[allow(dead_code)]
72    language: Language,
73    tags_query: Query,
74    capture_names: Vec<String>,
75    /// Compiled `locals.scm`, with its capture-name table. `None` when the
76    /// grammar ships no locals query.
77    locals: Option<CapturedQuery>,
78    /// Compiled `imports.scm`, with its capture-name table. `None` when the
79    /// grammar ships no imports query.
80    imports: Option<CapturedQuery>,
81}
82
83/// A compiled query plus its capture-name lookup table.
84struct CapturedQuery {
85    query: Query,
86    capture_names: Vec<String>,
87}
88
89impl CapturedQuery {
90    fn compile(language: &Language, src: &str, what: &str) -> Result<CapturedQuery> {
91        let query = Query::new(language, src).with_context(|| format!("compiling {what} query"))?;
92        let capture_names = query.capture_names().iter().map(|s| s.to_string()).collect();
93        Ok(CapturedQuery { query, capture_names })
94    }
95
96    /// Compile an *optional* query (`locals.scm` / `imports.scm`) without letting
97    /// a bad one break the grammar. These come from the registry and may be
98    /// authored upstream against a different grammar version; a query that fails
99    /// to compile (e.g. references a node kind this grammar doesn't have) must
100    /// degrade to "feature off", never poison the core tools. Returns `None` on
101    /// absence or compile error, warning once on the latter.
102    fn compile_optional(
103        language: &Language,
104        src: &Option<std::sync::Arc<String>>,
105        what: &str,
106    ) -> Option<CapturedQuery> {
107        let src = src.as_ref()?;
108        // Defense-in-depth: tree-sitter *supertype* patterns (`(super/sub)`) can
109        // hard-crash the wasm query engine at *match* time — not catchable like a
110        // compile error. Some upstream `locals.scm` use them. Refuse such a query
111        // outright (feature off) rather than risk a segfault on a hosted file.
112        if has_supertype_pattern(src) {
113            eprintln!("grove: ignoring {what} query (unsupported supertype `(a/b)` syntax)");
114            return None;
115        }
116        match CapturedQuery::compile(language, src, what) {
117            Ok(q) => Some(q),
118            Err(e) => {
119                eprintln!("grove: ignoring invalid {what} query: {e:#}");
120                None
121            }
122        }
123    }
124}
125
126/// Detect tree-sitter supertype node syntax `(name/name` outside string literals.
127/// Queries use `/` only for supertypes; predicate args containing `/` live inside
128/// quotes, which we skip. Conservative — a false positive just disables an
129/// optional feature, never breaks core tools.
130fn has_supertype_pattern(src: &str) -> bool {
131    let b = src.as_bytes();
132    let mut in_str = false;
133    let mut in_comment = false; // `;` to end-of-line — query comments often hold `/` ("if/else")
134    let is_ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
135    for i in 0..b.len() {
136        let c = b[i];
137        if in_comment {
138            if c == b'\n' {
139                in_comment = false;
140            }
141            continue;
142        }
143        match c {
144            b'"' if i == 0 || b[i - 1] != b'\\' => in_str = !in_str,
145            b';' if !in_str => in_comment = true,
146            b'/' if !in_str => {
147                let prev = i.checked_sub(1).map(|j| b[j]).unwrap_or(0);
148                let next = b.get(i + 1).copied().unwrap_or(0);
149                if is_ident(prev) && is_ident(next) {
150                    return true;
151                }
152            }
153            _ => {}
154        }
155    }
156    false
157}
158
159impl Loaded {
160    fn load(g: &Grammar) -> Result<Loaded> {
161        let mut store = WasmStore::new(engine()).map_err(|e| anyhow!("wasm store: {e:?}"))?;
162        let language = store
163            .load_language(&g.name, &g.wasm)
164            .map_err(|e| anyhow!("loading `{}` grammar from wasm: {e:?}", g.name))?;
165        let tags_query =
166            Query::new(&language, &g.tags_query).context("compiling tags query")?;
167        let capture_names = tags_query
168            .capture_names()
169            .iter()
170            .map(|s| s.to_string())
171            .collect();
172        let locals = CapturedQuery::compile_optional(&language, &g.locals_query, "locals");
173        let imports = CapturedQuery::compile_optional(&language, &g.imports_query, "imports");
174        let mut parser = Parser::new();
175        parser
176            .set_wasm_store(store)
177            .map_err(|e| anyhow!("attaching wasm store: {e}"))?;
178        parser
179            .set_language(&language)
180            .map_err(|e| anyhow!("setting language: {e}"))?;
181        Ok(Loaded { parser, language, tags_query, capture_names, locals, imports })
182    }
183}
184
185thread_local! {
186    static CACHE: RefCell<HashMap<String, Loaded>> = RefCell::new(HashMap::new());
187}
188
189fn with_loaded<R>(g: &Grammar, f: impl FnOnce(&mut Loaded) -> Result<R>) -> Result<R> {
190    CACHE.with(|c| {
191        let mut map = c.borrow_mut();
192        if !map.contains_key(&g.name) {
193            let loaded = Loaded::load(g)?;
194            map.insert(g.name.clone(), loaded);
195        }
196        f(map.get_mut(&g.name).unwrap())
197    })
198}
199
200// ---- extraction ----
201
202/// Parse `source` with `parser` — the single choke point for parsing, so cost
203/// (and, in tests, the parse count) lives in one place.
204fn parse_source(parser: &mut Parser, source: &[u8]) -> Result<tree_sitter::Tree> {
205    #[cfg(test)]
206    parse_counter::bump();
207    parser.parse(source, None).context("parse produced no tree")
208}
209
210/// Test-only parse counter, used to prove `callers` parses each file once.
211/// Thread-local so it counts only the parses on the calling test's thread —
212/// immune to other tests parsing in parallel.
213#[cfg(test)]
214pub mod parse_counter {
215    use std::cell::Cell;
216    thread_local! {
217        static COUNT: Cell<usize> = const { Cell::new(0) };
218    }
219    pub(super) fn bump() {
220        COUNT.with(|c| c.set(c.get() + 1));
221    }
222    pub fn reset() {
223        COUNT.with(|c| c.set(0));
224    }
225    pub fn get() -> usize {
226        COUNT.with(Cell::get)
227    }
228}
229
230fn symbol_id(lang: &str, rel: &str, name: &str, line: usize) -> String {
231    format!("{lang}:{rel}#{name}@{line}")
232}
233
234/// The trimmed source line containing `byte`.
235fn line_text(source: &[u8], byte: usize) -> String {
236    let start = source[..byte.min(source.len())]
237        .iter()
238        .rposition(|&b| b == b'\n')
239        .map_or(0, |i| i + 1);
240    let end = source[byte.min(source.len())..]
241        .iter()
242        .position(|&b| b == b'\n')
243        .map_or(source.len(), |i| byte + i);
244    String::from_utf8_lossy(&source[start..end]).trim().to_string()
245}
246
247/// Extract all tags (definitions + references) from one file's source.
248pub fn extract(grammar: &Grammar, rel: &str, source: &[u8]) -> Result<Vec<Symbol>> {
249    extract_with_tree(grammar, rel, source).map(|(syms, _)| syms)
250}
251
252/// Like [`extract`], but also returns the parsed tree so a caller that needs a
253/// second pass (e.g. `callers`' enclosing-function lookup) can reuse it instead
254/// of re-parsing the identical bytes. Parsing dominates tree-sitter cost.
255pub fn extract_with_tree(
256    grammar: &Grammar,
257    rel: &str,
258    source: &[u8],
259) -> Result<(Vec<Symbol>, tree_sitter::Tree)> {
260    with_loaded(grammar, |lg| {
261        let tree = parse_source(&mut lg.parser, source)?;
262        let mut cursor = QueryCursor::new();
263        let mut matches = cursor.matches(&lg.tags_query, tree.root_node(), source);
264
265        let mut out = Vec::new();
266        while let Some(m) = matches.next() {
267            let mut anchor: Option<(Node, String, bool)> = None;
268            let mut name_node: Option<Node> = None;
269            for cap in m.captures {
270                let cn = &lg.capture_names[cap.index as usize];
271                if let Some(kind) = cn.strip_prefix("definition.") {
272                    anchor = Some((cap.node, kind.to_string(), true));
273                } else if let Some(kind) = cn.strip_prefix("reference.") {
274                    anchor = Some((cap.node, kind.to_string(), false));
275                } else if cn == "name" {
276                    name_node = Some(cap.node);
277                }
278            }
279            let Some((node, kind, is_definition)) = anchor else {
280                continue;
281            };
282            let nn = name_node.unwrap_or(node);
283            let name = nn.utf8_text(source).unwrap_or("").to_string();
284            if name.is_empty() {
285                continue;
286            }
287            let pos = nn.start_position();
288            // Some upstream tags queries anchor `@definition.function` on a
289            // declarator (e.g. C's `function_declarator`) that spans only the
290            // signature, not the body. Expand to the enclosing full-function
291            // node so `source` returns the complete body. The name position and
292            // signature line stay anchored on the name itself.
293            let span = definition_span(node, &kind, &grammar.profile);
294            // tree-sitter `Point` is 0-based; surface 1-based line/col so the
295            // agent-facing handle and output read as human line numbers (#31).
296            let line = pos.row + 1;
297            out.push(Symbol {
298                id: symbol_id(&grammar.name, rel, &name, line),
299                name,
300                kind,
301                is_definition,
302                file: rel.to_string(),
303                line,
304                col: pos.column + 1,
305                start_byte: span.start_byte(),
306                end_byte: span.end_byte(),
307                signature: line_text(source, nn.start_byte()),
308                parent: None,
309            });
310        }
311
312        // Overlapping tag patterns can match the same node twice (e.g. a method
313        // matches both @definition.function and @definition.method). Keep the
314        // first match per (range, is_definition) — query order puts the more
315        // specific pattern first.
316        let mut seen = std::collections::HashSet::new();
317        out.retain(|s| seen.insert((s.start_byte, s.end_byte, s.is_definition)));
318
319        // Second pass: fill parents from the same tree. Search starts at the
320        // def node's *parent* so a container (e.g. a class) is never its own parent.
321        let root = tree.root_node();
322        for s in &mut out {
323            s.parent = root
324                .descendant_for_byte_range(s.start_byte, s.end_byte)
325                .and_then(|def| def.parent())
326                .and_then(|p| nearest_container(p, source, &grammar.profile));
327        }
328        Ok((out, tree))
329    })
330}
331
332/// The full source of a symbol, given its byte range.
333pub fn slice<'a>(source: &'a [u8], sym: &Symbol) -> &'a str {
334    std::str::from_utf8(&source[sym.start_byte..sym.end_byte]).unwrap_or("<non-utf8>")
335}
336
337/// Parse a file and report every ERROR / MISSING node.
338pub fn check(grammar: &Grammar, source: &[u8]) -> Result<Vec<Defect>> {
339    with_loaded(grammar, |lg| {
340        let tree = parse_source(&mut lg.parser, source)?;
341        let mut defects = Vec::new();
342        collect_defects(tree.root_node(), source, &mut defects);
343        Ok(defects)
344    })
345}
346
347fn collect_defects(node: Node, source: &[u8], out: &mut Vec<Defect>) {
348    if node.is_error() || node.is_missing() {
349        let start = node.start_position();
350        out.push(Defect {
351            kind: if node.is_missing() { "missing" } else { "error" },
352            line: start.row + 1,
353            col: start.column + 1,
354            start_byte: node.start_byte(),
355            end_byte: node.end_byte(),
356            text: String::from_utf8_lossy(&source[node.byte_range()])
357                .chars()
358                .take(60)
359                .collect(),
360        });
361    }
362    let mut cursor = node.walk();
363    for child in node.children(&mut cursor) {
364        collect_defects(child, source, out);
365    }
366}
367
368/// The node whose byte range best represents the *whole* function definition.
369///
370/// Upstream tags queries vary in what they anchor `@definition.function` on:
371/// Rust/Python capture the full function node (body included), but C's query
372/// anchors on `function_declarator`, which spans only the signature. When the
373/// captured node is such a declarator nested inside a full-function node (per
374/// the profile's `function_kinds`), climb to that node so the symbol's byte
375/// range covers the body. A bare prototype (no enclosing function node) keeps
376/// the declarator range — it has no body to miss.
377fn definition_span<'a>(node: Node<'a>, kind: &str, profile: &Profile) -> Node<'a> {
378    if kind != "function" && kind != "method" {
379        return node;
380    }
381    let is_fn_kind = |n: &Node| profile.function_kinds.iter().any(|k| k.as_str() == n.kind());
382    if is_fn_kind(&node) {
383        return node;
384    }
385    let mut cur = node.parent();
386    while let Some(n) = cur {
387        if is_fn_kind(&n) {
388            return n;
389        }
390        cur = n.parent();
391    }
392    node
393}
394
395// ---- position resolution (parent / enclosing-fn / go-to-def) ----
396
397/// Name of the nearest container (impl type / trait / class / module) at or
398/// above `node`, per the language profile. Pass the def node's parent to exclude
399/// the node itself.
400fn nearest_container(node: Node, source: &[u8], profile: &Profile) -> Option<String> {
401    let mut cur = Some(node);
402    while let Some(n) = cur {
403        for (kind, field) in &profile.containers {
404            if kind.as_str() == n.kind() {
405                if let Some(c) = n.child_by_field_name(field) {
406                    let text = c.utf8_text(source).ok()?;
407                    return Some(text.split('<').next().unwrap_or(text).trim().to_string());
408                }
409            }
410        }
411        cur = n.parent();
412    }
413    None
414}
415
416/// Run a closure with the parsed tree of `source` under `grammar`. The closure
417/// receives the root node and the grammar's profile.
418pub fn with_tree<R>(
419    grammar: &Grammar,
420    source: &[u8],
421    f: impl FnOnce(Node, &Profile) -> R,
422) -> Result<R> {
423    with_loaded(grammar, |lg| {
424        let tree = parse_source(&mut lg.parser, source)?;
425        Ok(f(tree.root_node(), &grammar.profile))
426    })
427}
428
429/// Name of a function node. Prefers a direct `name` field (Rust/Python/JS);
430/// falls back to descending the `declarator` chain to the identifier (C, where
431/// `function_definition` has no `name` field — the name sits under
432/// `function_declarator`, possibly through `pointer_declarator`).
433fn function_name(node: Node, source: &[u8], profile: &Profile) -> Option<String> {
434    if let Some(n) = node.child_by_field_name("name") {
435        return n.utf8_text(source).ok().map(str::to_string);
436    }
437    let mut cur = node.child_by_field_name("declarator")?;
438    loop {
439        if profile.identifier_kinds.iter().any(|k| k.as_str() == cur.kind()) {
440            return cur.utf8_text(source).ok().map(str::to_string);
441        }
442        cur = cur.child_by_field_name("declarator")?;
443    }
444}
445
446/// Name of the function/method enclosing `byte`, qualified by container.
447pub fn enclosing_function_at(
448    root: Node,
449    byte: usize,
450    source: &[u8],
451    profile: &Profile,
452) -> Option<String> {
453    let mut node = root.descendant_for_byte_range(byte, byte)?;
454    loop {
455        if profile.function_kinds.iter().any(|k| k.as_str() == node.kind()) {
456            let fname = function_name(node, source, profile)?;
457            let container = node
458                .parent()
459                .and_then(|p| nearest_container(p, source, profile));
460            return Some(match container {
461                Some(c) => format!("{c}::{fname}"),
462                None => fname,
463            });
464        }
465        node = node.parent()?;
466    }
467}
468
469/// The identifier text at a (row, col) position — for go-to-def.
470pub fn identifier_at(
471    root: Node,
472    row: usize,
473    col: usize,
474    source: &[u8],
475    profile: &Profile,
476) -> Option<String> {
477    let point = tree_sitter::Point { row, column: col };
478    let node = root.descendant_for_point_range(point, point)?;
479    if profile.identifier_kinds.iter().any(|k| k.as_str() == node.kind()) {
480        node.utf8_text(source).ok().map(str::to_string)
481    } else {
482        None
483    }
484}
485
486/// Build a `Symbol` for a locally-bound definition. The captured
487/// `@local.definition` node is the binding identifier; line/col anchor on it,
488/// while the byte span expands to its parent statement (the `let`, `parameter`,
489/// or `assignment`) so `source` returns the whole binding, not a bare name.
490fn local_symbol(grammar: &Grammar, rel: &str, name_node: Node, source: &[u8]) -> Symbol {
491    let pos = name_node.start_position();
492    let line = pos.row + 1;
493    let name = name_node.utf8_text(source).unwrap_or("").to_string();
494    let span = name_node.parent().unwrap_or(name_node);
495    Symbol {
496        id: symbol_id(&grammar.name, rel, &name, line),
497        name,
498        kind: "local".to_string(),
499        is_definition: true,
500        file: rel.to_string(),
501        line,
502        col: pos.column + 1,
503        start_byte: span.start_byte(),
504        end_byte: span.end_byte(),
505        signature: line_text(source, name_node.start_byte()),
506        parent: None,
507    }
508}
509
510/// Scope-aware go-to-def: resolve the identifier at `(row, col)` to its nearest
511/// enclosing **local** binding, using the grammar's `locals.scm`.
512///
513/// Returns `Ok(None)` when the grammar ships no locals query, the cursor is not
514/// on an identifier, or the name has no enclosing local definition (a free /
515/// global reference — the caller then falls back to directory-wide lookup).
516/// Innermost enclosing scope wins, so shadowing resolves correctly. This is a
517/// single-file, single-parse, stateless operation — no index.
518pub fn resolve_local_at(
519    grammar: &Grammar,
520    rel: &str,
521    source: &[u8],
522    row: usize,
523    col: usize,
524) -> Result<Option<Symbol>> {
525    with_loaded(grammar, |lg| {
526        let Some(locals) = &lg.locals else {
527            return Ok(None);
528        };
529        let tree = parse_source(&mut lg.parser, source)?;
530        let root = tree.root_node();
531
532        let point = tree_sitter::Point { row, column: col };
533        let Some(ref_node) = root.descendant_for_point_range(point, point) else {
534            return Ok(None);
535        };
536        if !grammar
537            .profile
538            .identifier_kinds
539            .iter()
540            .any(|k| k.as_str() == ref_node.kind())
541        {
542            return Ok(None);
543        }
544        let name = ref_node.utf8_text(source).unwrap_or("");
545        if name.is_empty() {
546            return Ok(None);
547        }
548
549        // Collect scope ranges and definition nodes from the locals query.
550        let mut scopes: Vec<Node> = Vec::new();
551        let mut defs: Vec<Node> = Vec::new();
552        let mut cursor = QueryCursor::new();
553        let mut matches = cursor.matches(&locals.query, root, source);
554        while let Some(m) = matches.next() {
555            for cap in m.captures {
556                // Prefix-match so subtyped captures from upstream files work too
557                // (e.g. julia's `@local.definition.function`, `@local.scope.*`).
558                let cn = locals.capture_names[cap.index as usize].as_str();
559                if cn.starts_with("local.scope") {
560                    scopes.push(cap.node);
561                } else if cn.starts_with("local.definition") {
562                    defs.push(cap.node);
563                }
564            }
565        }
566
567        // Scopes enclosing the reference, innermost (smallest span) first.
568        let (rs, re) = (ref_node.start_byte(), ref_node.end_byte());
569        let mut enclosing: Vec<Node> = scopes
570            .into_iter()
571            .filter(|s| s.start_byte() <= rs && s.end_byte() >= re)
572            .collect();
573        enclosing.sort_by_key(|s| s.end_byte() - s.start_byte());
574
575        for scope in &enclosing {
576            let hit = defs.iter().find(|d| {
577                d.start_byte() >= scope.start_byte()
578                    && d.end_byte() <= scope.end_byte()
579                    && d.utf8_text(source).map(|t| t == name).unwrap_or(false)
580            });
581            if let Some(d) = hit {
582                return Ok(Some(local_symbol(grammar, rel, *d, source)));
583            }
584        }
585        Ok(None)
586    })
587}
588
589/// One name brought into a file by an import statement (ADR 0001 Step 2).
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct ImportBinding {
592    /// The name as referenced in *this* file — the alias when aliased, else the
593    /// imported name. This is what a cursor on a use resolves against.
594    pub name: String,
595    /// The original name in the target module (what to look up there). Equals
596    /// `name` when the import is not aliased.
597    pub source: String,
598    /// The module path text, verbatim from the import (e.g. `foo.bar`, `./util`).
599    pub module: String,
600}
601
602/// Extract every import binding from a file, via the grammar's `imports.scm`
603/// (`@import.name` / optional `@import.source` / `@import.module`). Returns an
604/// empty vec when the grammar ships no imports query. Pure single-file parse.
605pub fn extract_imports(grammar: &Grammar, source: &[u8]) -> Result<Vec<ImportBinding>> {
606    with_loaded(grammar, |lg| {
607        let Some(imports) = &lg.imports else {
608            return Ok(Vec::new());
609        };
610        let tree = parse_source(&mut lg.parser, source)?;
611        let mut out = Vec::new();
612        let mut cursor = QueryCursor::new();
613        let mut matches = cursor.matches(&imports.query, tree.root_node(), source);
614        while let Some(m) = matches.next() {
615            let (mut name, mut src, mut module) = (None, None, None);
616            for cap in m.captures {
617                let text = cap.node.utf8_text(source).unwrap_or("").to_string();
618                match imports.capture_names[cap.index as usize].as_str() {
619                    "import.name" => name = Some(text),
620                    "import.source" => src = Some(text),
621                    "import.module" => module = Some(text),
622                    _ => {}
623                }
624            }
625            // A binding needs at least a bound name and a module to resolve.
626            if let (Some(name), Some(module)) = (name, module) {
627                let source = src.unwrap_or_else(|| name.clone());
628                out.push(ImportBinding { name, source, module });
629            }
630        }
631        Ok(out)
632    })
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use crate::registry;
639
640    fn rust() -> Grammar {
641        registry::resolve("rust").expect("rust grammar (dev stub or cache)")
642    }
643
644    /// Regression guard for #31: the reported `line` must equal the real
645    /// `grep -n` line of the definition (1-based), per grammar. The off-by-one
646    /// clustered by language, so this asserts across every dev-stub grammar with
647    /// the def deliberately placed below line 1 — where a 0-vs-1 slip would show.
648    #[test]
649    fn reported_line_matches_grep_n_per_grammar() {
650        // (lang, source, def name). `target` sits on the 3rd line in each — the
651        // line `grep -n target` would report.
652        let cases: &[(&str, &str, &str)] = &[
653            ("rust", "// header\n\nfn target() {}\n", "target"),
654            ("python", "# header\n\ndef target():\n    pass\n", "target"),
655            ("javascript", "// header\n\nfunction target() {}\n", "target"),
656        ];
657        for (lang, src, name) in cases {
658            let Ok(g) = registry::resolve(lang) else {
659                eprintln!("skipping {lang}: grammar not resolvable in this environment");
660                continue;
661            };
662            let want_line = src
663                .lines()
664                .position(|l| l.contains(&format!(" {name}")) || l.contains(&format!("{name}(")))
665                .map(|i| i + 1)
666                .expect("fixture contains the def");
667            let syms = extract(&g, &format!("demo.{lang}"), src.as_bytes()).unwrap();
668            let def = syms
669                .iter()
670                .find(|s| s.name == *name && s.is_definition)
671                .unwrap_or_else(|| panic!("{lang}: no def named {name}"));
672            assert_eq!(
673                def.line, want_line,
674                "{lang}: reported line {} != grep -n line {want_line}",
675                def.line
676            );
677            // The id's `@<line>` must carry the same 1-based line.
678            assert!(
679                def.id.ends_with(&format!("@{want_line}")),
680                "{lang}: id {} should end with @{want_line}",
681                def.id
682            );
683        }
684    }
685
686    #[test]
687    fn check_passes_clean_source() {
688        let defects = check(&rust(), b"fn main() {}\n").unwrap();
689        assert!(defects.is_empty(), "valid rust has no defects, got {defects:?}");
690    }
691
692    #[test]
693    fn check_reports_defects_on_broken_source() {
694        // Unbalanced delimiters force ERROR / MISSING nodes.
695        let defects = check(&rust(), b"fn main( {\n").unwrap();
696        assert!(!defects.is_empty(), "broken rust must report a defect");
697        assert!(defects.iter().all(|d| d.kind == "error" || d.kind == "missing"));
698        assert!(defects.iter().all(|d| d.end_byte >= d.start_byte));
699    }
700
701    #[test]
702    fn extract_finds_definitions_with_container_parent() {
703        let src = b"struct S;\nimpl S {\n    fn method(&self) {}\n}\n";
704        let syms = extract(&rust(), "lib.rs", src).unwrap();
705        let m = syms
706            .iter()
707            .find(|s| s.name == "method" && s.is_definition)
708            .expect("method definition");
709        assert_eq!(m.parent.as_deref(), Some("S"), "method's container is impl S");
710        assert!(m.id.starts_with("rust:lib.rs#method@"), "stable id, got {}", m.id);
711    }
712
713    #[test]
714    fn rust_definition_span_covers_the_whole_body() {
715        // Rust anchors @definition.function on the full function node, so the
716        // captured range already spans the body. Guards against regressing the
717        // common case while exercising the slice/range path.
718        let src = b"fn f() {\n    let x = 1;\n    x + 1\n}\n";
719        let syms = extract(&rust(), "lib.rs", src).unwrap();
720        let f = syms.iter().find(|s| s.name == "f" && s.is_definition).unwrap();
721        let body = slice(src, f);
722        assert!(body.starts_with("fn f()"), "starts at signature: {body:?}");
723        assert!(body.trim_end().ends_with('}'), "includes closing brace: {body:?}");
724    }
725
726    #[test]
727    fn c_function_definition_span_includes_the_body() {
728        // C's upstream tags query anchors @definition.function on
729        // `function_declarator` (signature only). The engine must expand the
730        // range to the enclosing `function_definition` so `source` returns the
731        // full body. Skip where the C grammar isn't installed (the dev stub
732        // ships rust/python/js only).
733        let Ok(c) = registry::resolve("c") else {
734            eprintln!("skipping: C grammar not resolvable in this environment");
735            return;
736        };
737        let src = b"static int *get_thing(const char *s,\n                      int n)\n{\n\tint total = 0;\n\treturn &total;\n}\n";
738        let syms = extract(&c, "demo.c", src).unwrap();
739        let f = syms
740            .iter()
741            .find(|s| s.name == "get_thing" && s.is_definition)
742            .expect("get_thing definition");
743        let body = slice(src, f);
744        assert!(body.contains("int total = 0"), "body included: {body:?}");
745        assert!(body.contains("return &total"), "body included: {body:?}");
746        assert!(body.trim_end().ends_with('}'), "closing brace included: {body:?}");
747        // The pointer return type sits above the declarator — expansion must
748        // reach the whole `function_definition`, not just the declarator.
749        assert!(body.starts_with("static int *"), "return type included: {body:?}");
750        // Name position still anchors on the identifier, not the expanded span.
751        assert_eq!(f.line, 1, "name on the first line (1-based)");
752    }
753
754    #[test]
755    fn c_callers_capture_calls_with_enclosing_function() {
756        // Two-part regression for #27: C's curated tags must capture
757        // `@reference.call`, and `enclosing_function_at` must resolve a C
758        // function's name through the declarator chain (C `function_definition`
759        // has no `name` field). Skip where the C grammar isn't installed.
760        let Ok(c) = registry::resolve("c") else {
761            eprintln!("skipping: C grammar not resolvable in this environment");
762            return;
763        };
764        let src = b"static int helper(int x) { return x + 1; }\nstatic int caller_one(void) { return helper(5); }\n";
765        let (syms, tree) = extract_with_tree(&c, "demo.c", src).unwrap();
766        let call = syms
767            .iter()
768            .find(|s| s.name == "helper" && !s.is_definition)
769            .expect("helper call captured as a reference");
770        assert_eq!(call.kind, "call", "call reference kind");
771        let enc = enclosing_function_at(tree.root_node(), call.start_byte, src, &c.profile);
772        assert_eq!(enc.as_deref(), Some("caller_one"), "enclosing fn resolved for C");
773    }
774
775    #[test]
776    fn extract_with_tree_returns_a_reusable_tree() {
777        let src = b"fn helper() {}\nfn caller() {\n    helper();\n}\n";
778        let (syms, tree) = extract_with_tree(&rust(), "lib.rs", src).unwrap();
779        assert!(syms.iter().any(|s| s.name == "helper" && s.is_definition));
780        // The returned tree is usable for the enclosing-function pass.
781        let call = syms.iter().find(|s| s.name == "helper" && !s.is_definition).unwrap();
782        let enc = enclosing_function_at(tree.root_node(), call.start_byte, src, &rust().profile);
783        assert_eq!(enc.as_deref(), Some("caller"));
784    }
785
786    #[test]
787    fn slice_returns_the_symbols_bytes() {
788        let src = b"fn only() { let x = 1; }\n";
789        let syms = extract(&rust(), "lib.rs", src).unwrap();
790        let f = syms.iter().find(|s| s.name == "only").unwrap();
791        let body = slice(src, f);
792        assert!(body.starts_with("fn only"));
793        assert!(body.contains("let x = 1"));
794    }
795
796    #[test]
797    fn identifier_at_resolves_the_name_under_the_cursor() {
798        let src = b"fn helper() {}\nfn caller() {\n    helper();\n}\n";
799        let g = rust();
800        let name = with_tree(&g, src, |root, profile| {
801            // row 2 (0-based), col 4 — start of `helper` in the call.
802            identifier_at(root, 2, 4, src, profile)
803        })
804        .unwrap();
805        assert_eq!(name.as_deref(), Some("helper"));
806    }
807
808    #[test]
809    fn enclosing_function_at_qualifies_method_with_its_type() {
810        let src = b"struct S;\nimpl S {\n    fn m(&self) {\n        let _ = 1;\n    }\n}\n";
811        let g = rust();
812        // A byte inside the method body.
813        let needle = src.windows(9).position(|w| w == b"let _ = 1").unwrap();
814        let enc = with_tree(&g, src, |root, profile| {
815            enclosing_function_at(root, needle, src, profile)
816        })
817        .unwrap();
818        assert_eq!(enc.as_deref(), Some("S::m"), "method qualified by container type");
819    }
820
821    /// 0-based (row, col) of a byte offset — mirrors tree-sitter's `Point`.
822    fn row_col(src: &str, byte: usize) -> (usize, usize) {
823        let before = &src[..byte];
824        let row = before.matches('\n').count();
825        let col = byte - before.rfind('\n').map_or(0, |i| i + 1);
826        (row, col)
827    }
828
829    /// The rust grammar *with* its `locals.scm`, or `None` when it resolved from a
830    /// root that ships no locals query (e.g. a populated OS cache on a dev box).
831    /// A clean checkout / CI resolves the dev tree `registry/`, which has it. The
832    /// `GROVE_REGISTRY`-pinned integration suite (`tests/cli.rs`) covers this path
833    /// unconditionally; these unit tests add white-box coverage when available.
834    fn rust_with_locals() -> Option<Grammar> {
835        let g = rust();
836        if g.locals_query.is_none() {
837            eprintln!("skipping: rust grammar resolved without locals.scm (non-dev-stub root)");
838            return None;
839        }
840        Some(g)
841    }
842
843    #[test]
844    fn resolve_local_prefers_the_shadowing_binding() {
845        let Some(g) = rust_with_locals() else { return };
846        // A local `run` shadows the module-level `fn run`. Go-to-def on the *use*
847        // of `run` must land on the local binding (line 3), not the global (line 1).
848        let src = "fn run() {}\nfn caller() {\n    let run = 1;\n    let _x = run;\n}\n";
849        let use_byte = src.rfind("run").unwrap(); // the `run` in `let _x = run;`
850        let (row, col) = row_col(src, use_byte);
851        let got = resolve_local_at(&g, "demo.rs", src.as_bytes(), row, col)
852            .unwrap()
853            .expect("a local binding should resolve");
854        assert_eq!(got.name, "run");
855        assert_eq!(got.kind, "local");
856        assert_eq!(got.line, 3, "must resolve to the local `let run`, not the global fn");
857        assert!(got.id.ends_with("@3"), "id carries the local's line, got {}", got.id);
858    }
859
860    #[test]
861    fn resolve_local_returns_none_for_a_global_reference() {
862        let Some(g) = rust_with_locals() else { return };
863        // `helper` has no local binding in scope — resolution must decline so the
864        // caller falls back to the directory-wide lookup.
865        let src = "fn helper() {}\nfn caller() {\n    helper();\n}\n";
866        let call_byte = src.rfind("helper").unwrap();
867        let (row, col) = row_col(src, call_byte);
868        let got = resolve_local_at(&g, "demo.rs", src.as_bytes(), row, col).unwrap();
869        assert!(got.is_none(), "a free/global name has no local binding, got {got:?}");
870    }
871
872    #[test]
873    fn resolve_local_resolves_a_parameter() {
874        let Some(g) = rust_with_locals() else { return };
875        // Go-to-def on a use of a parameter resolves to the parameter binding.
876        let src = "fn f(x: i32) -> i32 {\n    x + 1\n}\n";
877        let use_byte = src.rfind('x').unwrap(); // the `x` in `x + 1`
878        let (row, col) = row_col(src, use_byte);
879        let got = resolve_local_at(&g, "demo.rs", src.as_bytes(), row, col)
880            .unwrap()
881            .expect("parameter should resolve");
882        assert_eq!(got.name, "x");
883        assert_eq!(got.line, 1, "parameter is declared on line 1");
884    }
885
886    #[test]
887    fn resolve_local_returns_none_off_an_identifier() {
888        let Some(g) = rust_with_locals() else { return };
889        // Cursor on whitespace / punctuation is not an identifier — decline.
890        let src = "fn f() {\n    let y = 1;\n}\n";
891        let (row, col) = row_col(src, src.find('{').unwrap());
892        let got = resolve_local_at(&g, "demo.rs", src.as_bytes(), row, col).unwrap();
893        assert!(got.is_none());
894    }
895
896    /// A grammar *with* its `imports.scm`, or `None` when resolved from a root
897    /// that ships none (e.g. a populated OS cache). The `GROVE_REGISTRY`-pinned
898    /// integration suite covers this path unconditionally.
899    fn lang_with_imports(lang: &str) -> Option<Grammar> {
900        let g = registry::resolve(lang).ok()?;
901        if g.imports_query.is_none() {
902            eprintln!("skipping {lang}: no imports.scm (non-dev-stub root)");
903            return None;
904        }
905        Some(g)
906    }
907
908    #[test]
909    fn extract_imports_python_named_and_aliased() {
910        let Some(g) = lang_with_imports("python") else { return };
911        let src = b"from pkg.util import helper\nfrom pkg.mod import thing as t\n";
912        let imps = extract_imports(&g, src).unwrap();
913        assert!(
914            imps.contains(&ImportBinding {
915                name: "helper".into(),
916                source: "helper".into(),
917                module: "pkg.util".into(),
918            }),
919            "named import: {imps:?}"
920        );
921        assert!(
922            imps.contains(&ImportBinding {
923                name: "t".into(),
924                source: "thing".into(),
925                module: "pkg.mod".into(),
926            }),
927            "aliased import binds the alias, sources the original: {imps:?}"
928        );
929    }
930
931    #[test]
932    fn extract_imports_javascript_named_and_aliased() {
933        let Some(g) = lang_with_imports("javascript") else { return };
934        let src = b"import { compute } from \"./calc\";\nimport { compute as c } from \"./calc\";\n";
935        let imps = extract_imports(&g, src).unwrap();
936        assert!(
937            imps.contains(&ImportBinding {
938                name: "compute".into(),
939                source: "compute".into(),
940                module: "./calc".into(),
941            }),
942            "named import: {imps:?}"
943        );
944        assert!(
945            imps.contains(&ImportBinding {
946                name: "c".into(),
947                source: "compute".into(),
948                module: "./calc".into(),
949            }),
950            "aliased import: {imps:?}"
951        );
952    }
953
954    #[test]
955    fn supertype_pattern_detected_outside_strings() {
956        // Crash-prone tree-sitter supertype syntax is flagged...
957        assert!(has_supertype_pattern("(pattern/identifier) @local.definition"));
958        assert!(has_supertype_pattern("(expression/variable) @local.reference"));
959        // ...but ordinary queries and `/` inside predicate strings are not.
960        assert!(!has_supertype_pattern("(identifier) @local.reference"));
961        assert!(!has_supertype_pattern("((identifier) @x (#match? @x \"a/b\"))"));
962        assert!(!has_supertype_pattern("(call function: (identifier) @name)"));
963        // `/` inside a `;` comment is not supertype syntax (regression: nvim
964        // locals.scm carry comments like "; if/else", "; try/catch").
965        assert!(!has_supertype_pattern("; if/else\n(identifier) @local.reference"));
966    }
967
968    #[test]
969    fn extract_imports_empty_without_query() {
970        // Rust ships no imports.scm in the dev stub → no bindings, no error.
971        let imps = extract_imports(&rust(), b"use foo::bar;\n").unwrap();
972        assert!(imps.is_empty(), "rust has no imports query yet: {imps:?}");
973    }
974
975    #[test]
976    fn extract_dedups_overlapping_matches() {
977        // No symbol range appears twice with the same is_definition flag.
978        let src = b"struct S;\nimpl S {\n    fn a(&self) {}\n    fn b(&self) {}\n}\n";
979        let syms = extract(&rust(), "lib.rs", src).unwrap();
980        let mut seen = std::collections::HashSet::new();
981        for s in &syms {
982            assert!(seen.insert((s.start_byte, s.end_byte, s.is_definition)), "duplicate: {s:?}");
983        }
984    }
985}
986