Skip to main content

grove_core/
ops.rs

1//! The operations, as a library — the single engine both faces call.
2//!
3//! `main.rs` (CLI) formats these into human tables; `mcp.rs` (MCP server)
4//! serializes them to JSON. Grammars come from the registry, so these work for
5//! any registered language, not just one compiled in.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11
12use crate::engine::{self, Defect, Symbol};
13use crate::registry::{self, Grammar};
14use ignore::WalkBuilder;
15
16/// Read a file's bytes with a contextual error.
17pub fn read(path: &Path) -> Result<Vec<u8>> {
18    std::fs::read(path).with_context(|| format!("reading {}", path.display()))
19}
20
21/// Best-effort repo-relative path, for stable symbol ids.
22pub fn rel(path: &Path) -> String {
23    std::env::current_dir()
24        .ok()
25        .zip(path.canonicalize().ok())
26        .and_then(|(cwd, p)| p.strip_prefix(&cwd).ok().map(|r| r.display().to_string()))
27        .unwrap_or_else(|| path.display().to_string())
28}
29
30/// True if `path` is a generated declaration file grove should not index as
31/// source during a directory walk. TypeScript `.d.ts`/`.d.cts`/`.d.mts` files
32/// are type declarations with no implementation — often machine-generated
33/// (under `tests/baselines/`, `declarations/`, `dist/`). Indexing them points
34/// `symbols`/`definition`/`callers` at the decl instead of the real source and
35/// drops genuine call sites, so they are excluded from the walk. A single
36/// file requested explicitly via `outline`/`source`/`check` is still honored —
37/// this filter only governs the recursive indexing pass. (Issue #32.)
38fn is_generated_decl(path: &Path) -> bool {
39    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
40        return false;
41    };
42    name.ends_with(".d.ts") || name.ends_with(".d.cts") || name.ends_with(".d.mts")
43}
44
45/// Walk every registered-source file under `dir`, yielding `(grammar, relpath, source)`.
46/// Generated declaration files (`*.d.ts`, see [`is_generated_decl`]) are skipped
47/// so `symbols`/`definition`/`callers` answer from real source, not generated decls.
48fn for_each_source(dir: &Path, mut f: impl FnMut(&Grammar, &str, &[u8]) -> Result<()>) -> Result<()> {
49    for entry in WalkBuilder::new(dir).build() {
50        let entry = match entry {
51            Ok(e) => e,
52            Err(_) => continue,
53        };
54        let path = entry.path();
55        if !path.is_file() || !registry::is_source(path) || is_generated_decl(path) {
56            continue;
57        }
58        let grammar = registry::for_path(path)?;
59        let src = read(path)?;
60        f(&grammar, &rel(path), &src)?;
61    }
62    Ok(())
63}
64
65/// Whether a symbol of `sym_kind` satisfies a `--kind filter`. Exact match,
66/// plus synonyms so a natural term finds the umbrella kind grove actually emits:
67/// every grammar tags struct/class-likes as `class` (C/Rust structs, C unions),
68/// so `--kind struct`/`--kind union` still find them. The aliases map onto
69/// `class` only — no grammar emits `struct`/`union` as a kind — so this can only
70/// widen a match, never hide one.
71fn kind_matches(sym_kind: &str, filter: &str) -> bool {
72    sym_kind == filter
73        || (matches!(filter, "struct" | "union" | "record") && sym_kind == "class")
74}
75
76/// Compare a symbol's name against a lowercased query. By default **exact**
77/// (case-insensitive) equality — so `--name batch` returns `batch`, not
78/// `testCreateBatch` (issue #37). With `contains` true, falls back to substring
79/// matching (the `--name-contains` opt-in) for fuzzy exploration. Grammar-common:
80/// it operates on the already-extracted `Symbol.name` string, so one rule serves
81/// every language.
82fn name_matches(sym_name: &str, query_lc: &str, contains: bool) -> bool {
83    let sym_lc = sym_name.to_lowercase();
84    if contains {
85        sym_lc.contains(query_lc)
86    } else {
87        sym_lc == query_lc
88    }
89}
90
91/// List the definitions in one file — its symbol skeleton.
92///
93/// * `file` — path to the source file; its grammar is resolved from the registry
94///   by extension.
95/// * `kind` — optional kind filter (`"function"`, `"class"`, …); `struct` /
96///   `union` / `record` are synonyms for the umbrella `class` kind.
97///
98/// Returns the definition [`Symbol`]s only (references excluded), in source
99/// order. Errors if the file can't be read or no grammar is registered for it.
100pub fn outline(file: &Path, kind: Option<&str>) -> Result<Vec<Symbol>> {
101    let grammar = registry::for_path(file)?;
102    let src = read(file)?;
103    let mut syms = engine::extract(&grammar, &rel(file), &src)?;
104    syms.retain(|s| s.is_definition && kind.is_none_or(|k| kind_matches(&s.kind, k)));
105    Ok(syms)
106}
107
108/// Project symbols to a JSON array at a detail level, to keep payloads bounded:
109/// 0 = terse (kind/name/parent/line), 1 = default (adds id/col/signature, drops
110/// byte offsets — the agent addresses symbols by id, not offset), 2 = full.
111pub fn project(syms: &[Symbol], detail: u8) -> serde_json::Value {
112    use serde_json::{Map, Value};
113    if detail >= 2 {
114        return serde_json::to_value(syms).unwrap_or(Value::Null);
115    }
116    let arr = syms
117        .iter()
118        .map(|s| {
119            let mut m = Map::new();
120            if detail >= 1 {
121                m.insert("id".into(), s.id.clone().into());
122            }
123            m.insert("kind".into(), s.kind.clone().into());
124            m.insert("name".into(), s.name.clone().into());
125            if let Some(p) = &s.parent {
126                m.insert("parent".into(), p.clone().into());
127            }
128            m.insert("line".into(), s.line.into());
129            if detail >= 1 {
130                m.insert("col".into(), s.col.into());
131                m.insert("signature".into(), s.signature.clone().into());
132            }
133            Value::Object(m)
134        })
135        .collect();
136    Value::Array(arr)
137}
138
139/// Find symbols across a directory, gitignore-aware.
140///
141/// Walks every registered-source file under `dir` (skipping gitignored paths and
142/// generated declaration files) and collects the symbols that match the filters.
143///
144/// * `dir` — directory root to search.
145/// * `kind` — optional kind filter (with the `struct`→`class` synonyms).
146/// * `name` — optional name filter; **exact** (case-insensitive) unless
147///   `name_contains` is set.
148/// * `refs` — when `true`, include references as well as definitions.
149/// * `name_contains` — switch `name` matching from exact to substring.
150///
151/// Returns every matching [`Symbol`]. Errors if a file can't be read or lacks a
152/// registered grammar.
153pub fn symbols(
154    dir: &Path,
155    kind: Option<&str>,
156    name: Option<&str>,
157    refs: bool,
158    name_contains: bool,
159) -> Result<Vec<Symbol>> {
160    let name_lc = name.map(str::to_lowercase);
161    let mut all = Vec::new();
162    for_each_source(dir, |grammar, relpath, src| {
163        for s in engine::extract(grammar, relpath, src)? {
164            if !refs && !s.is_definition {
165                continue;
166            }
167            if kind.is_some_and(|k| !kind_matches(&s.kind, k)) {
168                continue;
169            }
170            if name_lc
171                .as_ref()
172                .is_some_and(|n| !name_matches(&s.name, n, name_contains))
173            {
174                continue;
175            }
176            all.push(s);
177        }
178        Ok(())
179    })?;
180    Ok(all)
181}
182
183/// The result of `source`: the chosen symbol's code, plus any other
184/// definitions that shared the name (so the agent can disambiguate).
185#[derive(Debug, Serialize)]
186pub struct SourceResult {
187    pub id: String,
188    pub source: String,
189    #[serde(skip_serializing_if = "Vec::is_empty")]
190    pub other_candidates: Vec<String>,
191}
192
193/// Return the full source text of one symbol.
194///
195/// * `id_or_file` — either a symbol id (`<lang>:<path>#<name>@<line>`) or, when
196///   `name` is `Some`, the path to the file containing the symbol.
197/// * `name` — when `Some`, look the symbol up by this name in `id_or_file`; when
198///   `None`, parse `id_or_file` as a full symbol id.
199///
200/// Returns a [`SourceResult`] with the chosen symbol's `source` plus any
201/// `other_candidates` that shared the name (for disambiguation). The `@<line>`
202/// suffix of an id selects among duplicates; otherwise the first definition wins.
203/// Errors on a malformed id or when no matching definition exists.
204pub fn source(id_or_file: &str, name: Option<&str>) -> Result<SourceResult> {
205    let (file, want, want_line): (PathBuf, String, Option<usize>) = match name {
206        Some(n) => (PathBuf::from(id_or_file), n.to_string(), None),
207        None => {
208            let rest = id_or_file.split_once(':').map_or(id_or_file, |(_, r)| r);
209            let (path, after) = rest
210                .split_once('#')
211                .context("symbol id must look like <lang>:<path>#<name>@<line>")?;
212            // The `@<line>` suffix disambiguates duplicate-named definitions; keep
213            // it so the requested symbol is the one returned.
214            let (name, line) = match after.split_once('@') {
215                Some((n, r)) => (n.to_string(), r.parse::<usize>().ok()),
216                None => (after.to_string(), None),
217            };
218            (PathBuf::from(path), name, line)
219        }
220    };
221
222    let grammar = registry::for_path(&file)?;
223    let src = read(&file)?;
224    let syms = engine::extract(&grammar, &rel(&file), &src)?;
225    let matches: Vec<&Symbol> = syms
226        .iter()
227        .filter(|s| s.is_definition && s.name == want)
228        .collect();
229
230    // Prefer the exact-line match when the id carried a line; otherwise (name
231    // mode, lineless id, or no line matched) fall back to the first definition.
232    let chosen = match want_line.and_then(|r| matches.iter().find(|s| s.line == r)) {
233        Some(c) => *c,
234        None => match matches.first() {
235            None => anyhow::bail!("no definition named `{want}` in {}", file.display()),
236            Some(c) => *c,
237        },
238    };
239    Ok(SourceResult {
240        id: chosen.id.clone(),
241        source: engine::slice(&src, chosen).to_string(),
242        other_candidates: matches
243            .iter()
244            .filter(|s| s.id != chosen.id)
245            .map(|s| s.id.clone())
246            .collect(),
247    })
248}
249
250/// Report the syntactic defects in one file.
251///
252/// * `file` — path to the source file to parse.
253///
254/// Returns every [`Defect`] (ERROR / MISSING nodes) tree-sitter finds — empty
255/// when the file parses cleanly. Reports syntax only, not type or semantic
256/// errors. Errors if the file can't be read or no grammar is registered for it.
257pub fn check(file: &Path) -> Result<Vec<Defect>> {
258    let grammar = registry::for_path(file)?;
259    let src = read(file)?;
260    engine::check(&grammar, &src)
261}
262
263/// How a [`CallSite`] was found. Structural sites are high-precision
264/// (tree-sitter name-resolved); textual sites fill recall gaps (whole-word grep
265/// matches the tags query missed — may include type annotations, imports, or
266/// comments). Serializes to `"structural"` / `"textual"` to keep the JSON
267/// surface agents see stable.
268#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
269#[serde(rename_all = "lowercase")]
270pub enum CallSource {
271    Structural,
272    Textual,
273}
274
275/// A site where a symbol is referenced (call site, type use, textual occurrence).
276#[derive(Debug, Serialize)]
277pub struct CallSite {
278    pub file: String,
279    /// 1-based line and column of the call (editor / `grep -n` convention).
280    pub line: usize,
281    pub col: usize,
282    /// The function/method that contains this reference (`Type::method` when known).
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub in_function: Option<String>,
285    /// The trimmed source text of the call's line.
286    pub text: String,
287    /// Provenance of this reference — structural (tag-resolved) vs textual (grep).
288    pub source: CallSource,
289}
290
291/// `callers` — every reference to `name` across `dir`, with enclosing function.
292///
293/// Two passes are merged and deduped:
294/// 1. **Structural** — tree-sitter tag-resolved references (all reference kinds:
295///    call, type, implementation, etc.). High precision but low recall for names
296///    that the tags query doesn't capture (e.g. class/type references in Java or
297///    Python).
298/// 2. **Textual** — whole-word grep for the name, for lines not already covered
299///    by a structural hit. Higher recall but lower precision (may include type
300///    annotations, imports, or comments). Each result carries a `source` field
301///    (`"structural"` or `"textual"`) so the agent can prioritise.
302///
303/// Name-based: matches *any* symbol with this name (the slice does not resolve
304/// receiver types). Honest over-match, documented for the agent.
305pub fn callers(dir: &Path, name: &str) -> Result<Vec<CallSite>> {
306    let mut out = Vec::new();
307    for_each_source(dir, |grammar, relpath, src| {
308        // Reuse the parse tree from extraction for the enclosing-function pass —
309        // parsing dominates cost, so re-parsing here would double the work.
310        let (syms, tree) = engine::extract_with_tree(grammar, relpath, src)?;
311        let root = tree.root_node();
312
313        // --- Structural pass: all non-definition tag-resolved references ---
314        // The previous `is_call_kind` filter excluded type references, impl
315        // references, etc., causing callers to return [] for heavily-used
316        // class/type names (issue #33). Including all reference kinds fixes
317        // that while the textual pass below fills the long tail.
318        let structurals: Vec<&Symbol> = syms
319            .iter()
320            .filter(|s| !s.is_definition && s.name == name)
321            .collect();
322        // Lines covered by structural hits OR structural definitions of the same
323        // name — the textual pass skips these to avoid duplicating references or
324        // surfacing the definition line itself (callers is for references, not
325        // definitions).
326        let mut skip_lines: std::collections::HashSet<usize> =
327            std::collections::HashSet::new();
328        for s in &structurals {
329            skip_lines.insert(s.line.saturating_sub(1));
330        }
331        for s in syms.iter().filter(|s| s.is_definition && s.name == name) {
332            skip_lines.insert(s.line.saturating_sub(1));
333        }
334        for s in &structurals {
335            out.push(CallSite {
336                in_function: engine::enclosing_function_at(
337                    root,
338                    s.start_byte,
339                    src,
340                    &grammar.profile,
341                ),
342                file: s.file.clone(),
343                line: s.line,
344                col: s.col,
345                text: s.signature.clone(),
346                source: CallSource::Structural,
347            });
348        }
349
350        // --- Textual pass: whole-word grep for the name ---
351        // Covers references that the tags query misses (type annotations, imports,
352        // dynamic dispatch, etc.). Only adds lines not already covered by structural
353        // hits or definitions, so there's no duplication.
354        let src_str = std::str::from_utf8(src).unwrap_or("");
355        for (row, line_text) in src_str.lines().enumerate() {
356            if skip_lines.contains(&row) {
357                continue; // already covered by a structural hit or definition
358            }
359            if !has_whole_word(line_text, name) {
360                continue;
361            }
362            // Find the byte offset of the first occurrence for enclosing-function.
363            let col = line_text.find(name).unwrap_or(0);
364            let byte = line_offset(src, row) + col;
365            out.push(CallSite {
366                in_function: engine::enclosing_function_at(root, byte, src, &grammar.profile),
367                file: relpath.to_string(),
368                line: row + 1,
369                col: col + 1,
370                text: line_text.trim().to_string(),
371                source: CallSource::Textual,
372            });
373        }
374
375        Ok(())
376    })?;
377    Ok(out)
378}
379
380/// Byte offset of the start of line `row` (0-based) in `source`.
381fn line_offset(source: &[u8], row: usize) -> usize {
382    let mut off = 0;
383    for _ in 0..row {
384        match source[off..].iter().position(|&b| b == b'\n') {
385            Some(pos) => off += pos + 1,
386            None => return source.len(),
387        }
388    }
389    off
390}
391
392/// Does `haystack` contain `needle` as a whole word?
393/// A word boundary is the start/end of `haystack` or a char that is not
394/// ASCII alphanumeric or underscore. This mirrors how `grep -w` works for
395/// identifier names, without requiring a regex dependency.
396fn has_whole_word(haystack: &str, needle: &str) -> bool {
397    let needle_bytes = needle.as_bytes();
398    let haystack_bytes = haystack.as_bytes();
399    if needle_bytes.is_empty() {
400        return false;
401    }
402    let mut i = 0;
403    while i + needle_bytes.len() <= haystack_bytes.len() {
404        if &haystack_bytes[i..i + needle_bytes.len()] == needle_bytes {
405            let before_ok = i == 0 || !is_ident_char(haystack_bytes[i - 1]);
406            let after_ok =
407                i + needle_bytes.len() == haystack_bytes.len()
408                    || !is_ident_char(haystack_bytes[i + needle_bytes.len()]);
409            if before_ok && after_ok {
410                return true;
411            }
412        }
413        // Advance: skip ahead past the current byte (handles multi-byte UTF-8
414        // correctly because we only match on ASCII needle/ident chars).
415        i += 1;
416    }
417    false
418}
419
420fn is_ident_char(b: u8) -> bool {
421    b.is_ascii_alphanumeric() || b == b'_'
422}
423
424/// Parse a `file:line:col` position string. `line`/`col` are 1-based on input
425/// (the editor / `grep -n` convention grove prints), and are returned as the
426/// 0-based row/col tree-sitter expects, so the location grove prints round-trips
427/// straight back into `--at`.
428pub fn parse_pos(s: &str) -> Result<(PathBuf, usize, usize)> {
429    let parts: Vec<&str> = s.rsplitn(3, ':').collect();
430    match parts.as_slice() {
431        [col, line, file] => {
432            let line: usize = line.parse().map_err(|_| anyhow::anyhow!("bad line in `{s}`"))?;
433            let col: usize = col.parse().map_err(|_| anyhow::anyhow!("bad col in `{s}`"))?;
434            Ok((
435                PathBuf::from(file),
436                line.saturating_sub(1),
437                col.saturating_sub(1),
438            ))
439        }
440        _ => anyhow::bail!("expected file:line:col, got `{s}`"),
441    }
442}
443
444/// A definition in a `map` result, with its outgoing references.
445#[derive(Debug, Serialize)]
446pub struct MapEntry {
447    pub id: String,
448    pub kind: String,
449    pub name: String,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub parent: Option<String>,
452    pub row: usize,
453    pub signature: String,
454    /// Names of other symbols this definition references (outgoing edges).
455    #[serde(skip_serializing_if = "Vec::is_empty")]
456    pub references: Vec<String>,
457}
458
459/// A file in a `map` result, with its definitions and their references.
460#[derive(Debug, Serialize)]
461pub struct FileMap {
462    pub file: String,
463    pub entries: Vec<MapEntry>,
464}
465
466/// `map` — compact structural map of a directory: every definition grouped by
467/// file, with each definition's outgoing references (which other symbols it
468/// calls or uses). No source bodies — just the dependency graph. Use this
469/// instead of many `symbols`+`source` calls when you need a broad picture of
470/// how code connects.
471pub fn map(
472    dir: &Path,
473    kind: Option<&str>,
474    name: Option<&str>,
475    name_contains: bool,
476) -> Result<Vec<FileMap>> {
477    let name_lc = name.map(str::to_lowercase);
478    let mut file_maps = Vec::new();
479    for_each_source(dir, |grammar, relpath, src| {
480        let syms = engine::extract(grammar, relpath, src)?;
481
482        // Collect matching definition indices, sorted by byte-range size
483        // ascending so innermost (narrowest) definitions come first. This
484        // lets us attribute each reference to its innermost enclosing def.
485        let mut defs: Vec<usize> = syms
486            .iter()
487            .enumerate()
488            .filter(|(_, s)| s.is_definition)
489            .filter(|(_, s)| kind.is_none_or(|k| kind_matches(&s.kind, k)))
490            .filter(|(_, s)| name_lc.as_ref().is_none_or(|n| name_matches(&s.name, n, name_contains)))
491            .map(|(i, _)| i)
492            .collect();
493        defs.sort_by_key(|&i| syms[i].end_byte - syms[i].start_byte);
494
495        // Attribute each reference to the innermost containing definition.
496        let mut ref_map: std::collections::HashMap<usize, Vec<String>> =
497            std::collections::HashMap::new();
498        for s in syms.iter() {
499            if s.is_definition {
500                continue;
501            }
502            for &d in &defs {
503                if s.start_byte >= syms[d].start_byte && s.end_byte <= syms[d].end_byte {
504                    ref_map.entry(d).or_default().push(s.name.clone());
505                    break; // first (narrowest) match wins
506                }
507            }
508        }
509
510        // Deduplicate reference names per definition.
511        for names in ref_map.values_mut() {
512            names.sort();
513            names.dedup();
514        }
515
516        // Build entries, sorted by row for deterministic output.
517        let mut entries: Vec<MapEntry> = defs
518            .iter()
519            .map(|&d| {
520                let s = &syms[d];
521                let mut refs = ref_map.remove(&d).unwrap_or_default();
522                // Remove self-references (e.g. recursive calls).
523                refs.retain(|n| n != &s.name);
524                MapEntry {
525                    id: s.id.clone(),
526                    kind: s.kind.clone(),
527                    name: s.name.clone(),
528                    parent: s.parent.clone(),
529                    row: s.line,
530                    signature: s.signature.clone(),
531                    references: refs,
532                }
533            })
534            .collect();
535        entries.sort_by_key(|e| e.row);
536
537        if !entries.is_empty() {
538            file_maps.push(FileMap {
539                file: relpath.to_string(),
540                entries,
541            });
542        }
543        Ok(())
544    })?;
545    file_maps.sort_by(|a, b| a.file.cmp(&b.file));
546    Ok(file_maps)
547}
548
549/// Find the definition(s) of `name` across `dir` — go-to-def by name.
550///
551/// * `dir` — directory root to search.
552/// * `name` — the exact (case-sensitive) symbol name to resolve.
553///
554/// Returns every definition [`Symbol`] whose name matches exactly — usually one,
555/// but several when the name is reused across files. Errors if a file can't be
556/// read or lacks a registered grammar. For usage-site resolution (scope- and
557/// import-aware), see [`definition_at`].
558pub fn definition(dir: &Path, name: &str) -> Result<Vec<Symbol>> {
559    // `name_contains = false`: `definition` is exact by contract, so pre-filter
560    // exactly — cheaper than pulling every substring hit then retaining.
561    let mut defs = symbols(dir, None, Some(name), false, false)?;
562    defs.retain(|s| s.name == name);
563    Ok(defs)
564}
565
566/// `definition --at` — resolve the identifier at a usage site, then find its
567/// definition(s). `row`/`col` are 0-based tree-sitter coords (callers feed the
568/// output of [`parse_pos`], which converts the 1-based `file:line:col` users
569/// type). Returns the resolved name alongside the matches.
570pub fn definition_at(file: &Path, row: usize, col: usize, dir: &Path) -> Result<(String, Vec<Symbol>)> {
571    let grammar = registry::for_path(file)?;
572    let src = read(file)?;
573    let name = engine::with_tree(&grammar, &src, |root, profile| {
574        engine::identifier_at(root, row, col, &src, profile)
575    })?
576    .with_context(|| format!("no identifier at {}:{row}:{col}", file.display()))?;
577    // Scope-aware first (ADR 0001 Step 1): if the cursor's identifier binds to a
578    // local definition in an enclosing scope, that single binding *is* the
579    // answer — a shadowing local must win over a same-named global. Only when
580    // there is no local binding do we fall back to the directory-wide name
581    // lookup (the historical behavior, also the floor for free/global names).
582    if let Some(local) = engine::resolve_local_at(&grammar, &rel(file), &src, row, col)? {
583        return Ok((name, vec![local]));
584    }
585    // Import-edge next (ADR 0001 Step 2): if the name is brought in by an import,
586    // resolve it to the definition in the target file — cross-file go-to-def
587    // without an index. Falls through to directory-wide lookup on any miss.
588    let imported = resolve_import_at(file, &name, dir, &grammar, &src)?;
589    if !imported.is_empty() {
590        return Ok((name, imported));
591    }
592    let defs = definition(dir, &name)?;
593    Ok((name, defs))
594}
595
596/// Resolve `name` to a definition in the file an import binds it from. Returns an
597/// empty vec when the language has no import-resolution strategy, `name` isn't
598/// imported here, the module doesn't resolve to a file on disk, or that file has
599/// no matching top-level definition — every case the caller treats as "fall back
600/// to directory-wide lookup". Parses at most one extra file (the target), so the
601/// whole operation stays stateless and bounded by import depth, not repo size.
602fn resolve_import_at(
603    file: &Path,
604    name: &str,
605    dir: &Path,
606    grammar: &Grammar,
607    src: &[u8],
608) -> Result<Vec<Symbol>> {
609    let Some(strategy) = grammar.profile.import_resolution.as_deref() else {
610        return Ok(Vec::new());
611    };
612    let Some(binding) = engine::extract_imports(grammar, src)?
613        .into_iter()
614        .find(|b| b.name == name)
615    else {
616        return Ok(Vec::new());
617    };
618    for cand in import_candidate_paths(strategy, &binding.module, file, dir) {
619        if !cand.is_file() {
620            continue;
621        }
622        let tsrc = read(&cand)?;
623        let trel = rel(&cand);
624        let defs: Vec<Symbol> = engine::extract(grammar, &trel, &tsrc)?
625            .into_iter()
626            .filter(|s| s.is_definition && s.name == binding.source)
627            .collect();
628        if !defs.is_empty() {
629            return Ok(defs);
630        }
631    }
632    Ok(Vec::new())
633}
634
635/// Candidate file paths a module path could resolve to, by strategy. The first
636/// that exists and contains the imported definition wins. Pure path arithmetic —
637/// no IO here.
638fn import_candidate_paths(
639    strategy: &str,
640    module: &str,
641    current_file: &Path,
642    dir: &Path,
643) -> Vec<PathBuf> {
644    match strategy {
645        // Python: `foo.bar` → `<root>/foo/bar.py` | `<root>/foo/bar/__init__.py`.
646        // Leading dots are relative to the current file's package: one dot = the
647        // file's own directory, each extra dot climbs one more.
648        "dotted_package" => {
649            let dots = module.chars().take_while(|&c| c == '.').count();
650            let rest = &module[dots..];
651            let parts: Vec<&str> = rest.split('.').filter(|p| !p.is_empty()).collect();
652            let mut base = if dots == 0 {
653                dir.to_path_buf()
654            } else {
655                let mut b = current_file.parent().unwrap_or(dir).to_path_buf();
656                for _ in 0..dots.saturating_sub(1) {
657                    b = b.parent().map(Path::to_path_buf).unwrap_or(b);
658                }
659                b
660            };
661            for p in &parts {
662                base.push(p);
663            }
664            vec![base.with_extension("py"), base.join("__init__.py")]
665        }
666        // JS/TS: only relative specifiers resolve to a file; bare specifiers
667        // (`react`) are package imports we don't chase. `./util` → `./util.js` |
668        // `./util.jsx` | `./util/index.js`.
669        "relative_path" => {
670            if !module.starts_with('.') {
671                return Vec::new();
672            }
673            let joined = normalize(&current_file.parent().unwrap_or(dir).join(module));
674            if joined.extension().is_some() {
675                return vec![joined];
676            }
677            vec![
678                joined.with_extension("js"),
679                joined.with_extension("jsx"),
680                joined.join("index.js"),
681            ]
682        }
683        _ => Vec::new(),
684    }
685}
686
687/// Lexically normalize a path, resolving `.` and `..` without touching the disk
688/// (so `a/b/../util` → `a/util`). Symlink-blind, which is fine for module paths.
689fn normalize(path: &Path) -> PathBuf {
690    use std::path::Component;
691    let mut out = PathBuf::new();
692    for comp in path.components() {
693        match comp {
694            Component::CurDir => {}
695            Component::ParentDir => {
696                out.pop();
697            }
698            other => out.push(other.as_os_str()),
699        }
700    }
701    out
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    /// Two definitions named `run`, at rows 0 and 4.
709    const DUP: &str =
710        "fn run() {\n    let _first = 1;\n}\n\nfn run() {\n    let _second = 2;\n}\n";
711
712    fn write_temp(tag: &str, contents: &str) -> PathBuf {
713        let mut p = std::env::temp_dir();
714        p.push(format!("grove_src_test_{}_{tag}.rs", std::process::id()));
715        std::fs::write(&p, contents).unwrap();
716        p
717    }
718
719    #[test]
720    fn id_line_selects_that_definition() {
721        let path = write_temp("dup_line", DUP);
722
723        // The 2nd `run` starts on the 5th line (1-based) of DUP.
724        let res = source(&format!("rust:{}#run@5", path.display()), None).unwrap();
725        assert!(res.source.contains("_second"), "line 5 must pick the 2nd run, got: {}", res.source);
726        assert!(res.id.ends_with("@5"), "chosen id should be the line-5 def, got {}", res.id);
727
728        let res0 = source(&format!("rust:{}#run@1", path.display()), None).unwrap();
729        assert!(res0.source.contains("_first"), "line 1 must pick the 1st run, got: {}", res0.source);
730
731        std::fs::remove_file(&path).ok();
732    }
733
734    #[test]
735    fn unmatched_line_falls_back_to_first() {
736        let path = write_temp("dup_fallback", DUP);
737        let res = source(&format!("rust:{}#run@99", path.display()), None).unwrap();
738        assert!(res.source.contains("_first"), "unknown line falls back to the first def");
739        std::fs::remove_file(&path).ok();
740    }
741
742    #[test]
743    fn by_name_returns_first_and_lists_other_candidate() {
744        let path = write_temp("dup_name", DUP);
745        let res = source(path.to_str().unwrap(), Some("run")).unwrap();
746        assert!(res.source.contains("_first"));
747        assert_eq!(res.other_candidates.len(), 1, "the 2nd run is the other candidate");
748        std::fs::remove_file(&path).ok();
749    }
750
751    #[test]
752    fn callers_finds_call_sites_via_profile() {
753        // `helper` is called once; the profile-driven call filter (#10) must
754        // still surface the `@reference.call` site for the dev-stub rust grammar.
755        let dir = std::env::temp_dir().join(format!("grove_callers_test_{}", std::process::id()));
756        std::fs::create_dir_all(&dir).unwrap();
757        let file = dir.join("lib.rs");
758        std::fs::write(&file, "fn helper() {}\nfn main() {\n    helper();\n}\n").unwrap();
759
760        let sites = callers(&dir, "helper").unwrap();
761        assert_eq!(sites.len(), 1, "exactly one call to helper, got {sites:?}");
762        assert_eq!(sites[0].in_function.as_deref(), Some("main"));
763
764        std::fs::remove_dir_all(&dir).ok();
765    }
766
767    #[test]
768    fn callers_parses_each_file_once() {
769        // #13: `callers` used to parse every matched file twice (extract +
770        // with_tree). It must now parse each source file exactly once.
771        let dir = std::env::temp_dir().join(format!("grove_parse_once_test_{}", std::process::id()));
772        std::fs::create_dir_all(&dir).unwrap();
773        // Three files; two contain a call to `helper`, one does not. All three
774        // are still parsed once each by the single extraction pass.
775        std::fs::write(dir.join("a.rs"), "fn main() {\n    helper();\n}\n").unwrap();
776        std::fs::write(dir.join("b.rs"), "fn run() {\n    helper();\n}\n").unwrap();
777        std::fs::write(dir.join("c.rs"), "fn unrelated() {}\n").unwrap();
778
779        engine::parse_counter::reset();
780        let sites = callers(&dir, "helper").unwrap();
781        let parses = engine::parse_counter::get();
782
783        assert_eq!(sites.len(), 2, "two call sites, got {sites:?}");
784        assert_eq!(parses, 3, "expected one parse per source file (3), got {parses}");
785
786        std::fs::remove_dir_all(&dir).ok();
787    }
788
789    #[test]
790    fn callers_includes_type_and_impl_references() {
791        // Issue #33: callers previously filtered to is_call_kind only, returning []
792        // for heavily-used type/class names. Now all non-definition references are
793        // included (call, type, implementation, etc.).
794        let dir = std::env::temp_dir().join(format!("grove_callers_type_test_{}", std::process::id()));
795        std::fs::create_dir_all(&dir).unwrap();
796        // Rust tags query captures `impl Trait for Type` with the trait name as
797        // @reference.implementation — a non-call kind. `Clone` appears as a
798        // structural reference (kind "implementation") that was previously filtered
799        // out by is_call_kind.
800        std::fs::write(
801            dir.join("lib.rs"),
802            "struct Thing;\nimpl Clone for Thing {}\n",
803        ).unwrap();
804        let sites = callers(&dir, "Clone").unwrap();
805        // The `impl Clone for Thing` reference is structural (tag-resolved) with
806        // kind "implementation", not "call" — previously filtered out.
807        let structural = sites.iter().filter(|s| s.source == CallSource::Structural).count();
808        assert!(structural >= 1, "should find impl reference to Clone, got {sites:?}");
809        // No definition of `Clone` in this file, so no lines are skipped.
810        assert!(sites.iter().any(|s| s.in_function.is_none()), "impl is top-level, got {sites:?}");
811        std::fs::remove_dir_all(&dir).ok();
812    }
813
814    #[test]
815    fn callers_textual_fallback_finds_untagged_references() {
816        // Issue #33: when the tags query misses references to a name, the textual
817        // fallback finds them via whole-word grep.
818        let dir = std::env::temp_dir().join(format!("grove_callers_textual_test_{}", std::process::id()));
819        std::fs::create_dir_all(&dir).unwrap();
820        // `Scanner` appears as a type annotation and in a string — not captured by
821        // tags as a reference, but the textual pass should find them.
822        std::fs::write(
823            dir.join("lib.rs"),
824            "fn go(s: Scanner) { let x: Scanner = s; }\n",
825        ).unwrap();
826        let sites = callers(&dir, "Scanner").unwrap();
827        let textual: Vec<&CallSite> = sites.iter().filter(|s| s.source == CallSource::Textual).collect();
828        // The type annotations `s: Scanner` and `x: Scanner` should be found as
829        // textual matches (not captured by Rust's tags query as references).
830        assert!(!textual.is_empty(), "textual fallback should find type-annotation references to Scanner, got {sites:?}");
831        // Line 0 has `Scanner` twice (s: Scanner and x: Scanner) — but has_whole_word
832        // finds the line; we report one call site per line.
833        assert_eq!(textual.len(), 1, "one textual line containing Scanner, got {textual:?}");
834        std::fs::remove_dir_all(&dir).ok();
835    }
836
837    #[test]
838    fn callers_excludes_definition_from_textual() {
839        // The definition line should not appear in callers results (it's not a
840        // reference). The textual pass skips lines that have a structural definition.
841        let dir = std::env::temp_dir().join(format!("grove_callers_nodef_test_{}", std::process::id()));
842        std::fs::create_dir_all(&dir).unwrap();
843        std::fs::write(
844            dir.join("lib.rs"),
845            "fn helper() {}\nfn main() { helper(); }\n",
846        ).unwrap();
847        let sites = callers(&dir, "helper").unwrap();
848        // Row 0 is the definition line — should NOT appear.
849        assert!(!sites.iter().any(|s| s.line == 1), "definition line should not be in callers results, got {sites:?}");
850        // Row 1 is the call site — should appear.
851        assert!(sites.iter().any(|s| s.line == 2), "call site line should be in callers results, got {sites:?}");
852        std::fs::remove_dir_all(&dir).ok();
853    }
854
855    #[test]
856    fn has_whole_word_finds_identifier_names() {
857        assert!(has_whole_word("    helper()", "helper"));
858        assert!(has_whole_word("fn helper() {}", "helper"));
859        assert!(has_whole_word("s: Scanner", "Scanner"));
860        assert!(has_whole_word("Scanner::new()", "Scanner"));
861        assert!(has_whole_word("use crate::Scanner;", "Scanner"));
862        // Not a whole word — part of a larger identifier.
863        assert!(!has_whole_word("helper_fn()", "helper"));
864        assert!(!has_whole_word("myhelper()", "helper"));
865        assert!(!has_whole_word("MyScanner", "Scanner"));
866        assert!(!has_whole_word("scanner_new", "Scanner"));
867        // Empty needle.
868        assert!(!has_whole_word("anything", ""));
869        // Needle longer than haystack.
870        assert!(!has_whole_word("ab", "abc"));
871        // Exact match.
872        assert!(has_whole_word("helper", "helper"));
873    }
874
875    // ---- parse_pos ----
876
877    #[test]
878    fn parse_pos_parses_file_line_col() {
879        // 1-based `line:col` input is returned as 0-based row/col.
880        let (file, row, col) = parse_pos("src/lib.rs:12:4").unwrap();
881        assert_eq!(file, PathBuf::from("src/lib.rs"));
882        assert_eq!((row, col), (11, 3));
883    }
884
885    #[test]
886    fn parse_pos_keeps_colons_in_the_path() {
887        // rsplitn(3) means only the last two colons split line/col; a path with a
888        // colon (or a Windows drive) stays intact.
889        let (file, row, col) = parse_pos("a:b/file.rs:3:7").unwrap();
890        assert_eq!(file, PathBuf::from("a:b/file.rs"));
891        assert_eq!((row, col), (2, 6));
892    }
893
894    #[test]
895    fn parse_pos_rejects_bad_shapes() {
896        assert!(parse_pos("no-colons").is_err());
897        assert!(parse_pos("file.rs:notarow:4").unwrap_err().to_string().contains("bad line"));
898        assert!(parse_pos("file.rs:4:notacol").unwrap_err().to_string().contains("bad col"));
899    }
900
901    // ---- project (detail tiers) ----
902
903    #[test]
904    fn project_tiers_control_field_density() {
905        let dir = std::env::temp_dir().join(format!("grove_project_test_{}", std::process::id()));
906        std::fs::create_dir_all(&dir).unwrap();
907        let file = dir.join("lib.rs");
908        std::fs::write(&file, "struct S;\nimpl S {\n    fn m(&self) {}\n}\n").unwrap();
909        let syms = outline(&file, None).unwrap();
910
911        let terse = project(&syms, 0);
912        let first = &terse.as_array().unwrap()[0];
913        assert!(first.get("id").is_none(), "detail 0 omits id");
914        assert!(first.get("signature").is_none(), "detail 0 omits signature");
915        assert!(first.get("kind").is_some() && first.get("name").is_some());
916
917        let default = project(&syms, 1);
918        let d0 = &default.as_array().unwrap()[0];
919        assert!(d0.get("id").is_some(), "detail 1 adds id");
920        assert!(d0.get("signature").is_some(), "detail 1 adds signature");
921        assert!(d0.get("start_byte").is_none(), "detail 1 drops byte offsets");
922
923        let full = project(&syms, 2);
924        let f0 = &full.as_array().unwrap()[0];
925        assert!(f0.get("start_byte").is_some(), "detail 2 includes byte offsets");
926
927        std::fs::remove_dir_all(&dir).ok();
928    }
929
930    // ---- outline / symbols filters ----
931
932    #[test]
933    fn outline_filters_by_kind_and_skips_references() {
934        let dir = std::env::temp_dir().join(format!("grove_outline_test_{}", std::process::id()));
935        std::fs::create_dir_all(&dir).unwrap();
936        let file = dir.join("lib.rs");
937        std::fs::write(&file, "struct S;\nfn f() {\n    g();\n}\n").unwrap();
938
939        let all = outline(&file, None).unwrap();
940        assert!(all.iter().all(|s| s.is_definition), "outline yields definitions only");
941        assert!(all.iter().any(|s| s.name == "S"));
942        assert!(all.iter().any(|s| s.name == "f"));
943
944        // The rust tags map `struct` to the `class` kind.
945        let classes = outline(&file, Some("class")).unwrap();
946        assert!(classes.iter().all(|s| s.kind == "class"));
947        assert!(classes.iter().any(|s| s.name == "S"));
948        assert!(!classes.iter().any(|s| s.name == "f"), "kind filter excludes fns");
949
950        // `--kind struct` is a synonym for `class` so a natural term still finds it.
951        let structs = outline(&file, Some("struct")).unwrap();
952        assert!(structs.iter().any(|s| s.name == "S"), "struct aliases to class");
953        assert!(!structs.iter().any(|s| s.name == "f"));
954
955        std::fs::remove_dir_all(&dir).ok();
956    }
957
958    #[test]
959    fn is_generated_decl_flags_typescript_declaration_files() {
960        // Issue #32: `.d.ts`/`.d.cts`/`.d.mts` are generated declarations — they
961        // must be skipped by the directory walk so symbols/definition/callers
962        // answer from real source, not the decl. The check is suffix-based so it
963        // is independent of the registry (the typescript grammar may be absent).
964        assert!(is_generated_decl(Path::new("src/compiler/scanner.d.ts")));
965        assert!(is_generated_decl(Path::new("tests/baselines/reference/api/typescript.d.ts")));
966        assert!(is_generated_decl(Path::new("declarations/LoaderContext.d.ts")));
967        assert!(is_generated_decl(Path::new("pkg/index.d.cts")));
968        assert!(is_generated_decl(Path::new("pkg/index.d.mts")));
969
970        // Real implementation files and other paths are left alone.
971        assert!(!is_generated_decl(Path::new("src/compiler/scanner.ts")));
972        assert!(!is_generated_decl(Path::new("lib/Compiler.js")));
973        assert!(!is_generated_decl(Path::new("types.ts")), "`types.ts` is real source, not `types.d.ts`");
974        assert!(!is_generated_decl(Path::new("README.md")));
975        assert!(!is_generated_decl(Path::new("no_extension")));
976    }
977
978    #[test]
979    fn symbols_skips_generated_declaration_files() {
980        // Issue #32: a `.d.ts` file in the tree must not contribute symbols,
981        // even when a registered grammar would otherwise accept its extension.
982        // Here the dev-stub registry has no typescript grammar, so `.d.ts` is
983        // already not source — but a `.d.js`-style nested decl under a real
984        // registered extension is the closest in-stub analog. We instead assert
985        // the skip at the predicate level (see is_generated_decl_flags_*).
986        // This test pins that a real `.js` decl-like name is still indexed (i.e.
987        // the filter is suffix-precise and does not over-reach onto `.js`).
988        let dir = std::env::temp_dir().join(format!("grove_nodecl_test_{}", std::process::id()));
989        std::fs::create_dir_all(dir.join("declarations")).unwrap();
990        std::fs::write(dir.join("declarations/LoaderContext.d.ts"), "export class LoaderContext {}").unwrap();
991        std::fs::write(dir.join("lib.js"), "class Compiler {}").unwrap();
992
993        let defs = symbols(&dir, None, Some("Compiler"), false, false).unwrap();
994        assert!(defs.iter().any(|s| s.name == "Compiler"), "real source is indexed");
995        // No symbol named `LoaderContext` leaks in from the `.d.ts` (typescript is
996        // not registered here, but this also guards against a future regression where
997        // the filter stops being applied in the walk).
998        assert!(!defs.iter().any(|s| s.name == "LoaderContext"), "generated decl is skipped");
999
1000        std::fs::remove_dir_all(&dir).ok();
1001    }
1002
1003    #[test]
1004    fn kind_matches_exact_and_struct_synonyms() {
1005        assert!(kind_matches("class", "class"));
1006        assert!(kind_matches("class", "struct"), "struct → class");
1007        assert!(kind_matches("class", "union"), "union → class");
1008        assert!(kind_matches("function", "function"));
1009        assert!(!kind_matches("function", "struct"), "synonyms only widen onto class");
1010        assert!(!kind_matches("variable", "class"));
1011    }
1012
1013    #[test]
1014    fn symbols_honors_name_kind_and_refs_filters() {
1015        let dir = std::env::temp_dir().join(format!("grove_symbols_test_{}", std::process::id()));
1016        std::fs::create_dir_all(&dir).unwrap();
1017        std::fs::write(dir.join("lib.rs"), "fn alpha() {}\nfn beta() {\n    alpha();\n}\n").unwrap();
1018
1019        // Definitions only by default.
1020        let defs = symbols(&dir, None, None, false, false).unwrap();
1021        assert!(defs.iter().all(|s| s.is_definition));
1022
1023        // With refs, the call site shows up too (exact name match).
1024        let with_refs = symbols(&dir, None, Some("alpha"), true, false).unwrap();
1025        assert!(with_refs.iter().any(|s| !s.is_definition && s.name == "alpha"));
1026
1027        // `--name` is exact and case-insensitive by default (issue #37): "ALPHA"
1028        // matches `alpha`, but a substring like "alp" does NOT.
1029        let named = symbols(&dir, None, Some("ALPHA"), false, false).unwrap();
1030        assert!(named.iter().any(|s| s.name == "alpha"));
1031        assert!(!named.iter().any(|s| s.name == "beta"));
1032        let not_substr = symbols(&dir, None, Some("alp"), false, false).unwrap();
1033        assert!(
1034            not_substr.is_empty(),
1035            "exact mode must not substring-match 'alp' onto 'alpha'"
1036        );
1037
1038        // `name_contains` restores the substring behaviour.
1039        let substr = symbols(&dir, None, Some("alp"), false, true).unwrap();
1040        assert!(substr.iter().any(|s| s.name == "alpha"));
1041
1042        std::fs::remove_dir_all(&dir).ok();
1043    }
1044
1045    #[test]
1046    fn symbols_name_exact_buries_substring_noise_issue_37() {
1047        // Mirrors the issue's repro: `--name batch` used to return ~176 substring
1048        // hits (testCreateBatch, updateBatchName, ...) burying the real `batch`
1049        // constructor at row ~140, forcing a grep fallback. Exact matching lifts
1050        // the target to the top; --name-contains keeps the fuzzy path.
1051        let dir = std::env::temp_dir().join(format!("grove_symbols_issue37_{}", std::process::id()));
1052        std::fs::create_dir_all(&dir).unwrap();
1053        std::fs::write(
1054            dir.join("lib.rs"),
1055            "fn test_create_batch() {}\nfn update_batch_name() {}\nfn batch() {}\n",
1056        )
1057        .unwrap();
1058
1059        // Exact: `--name batch` returns only `batch`.
1060        let exact = symbols(&dir, None, Some("batch"), false, false).unwrap();
1061        let exact_names: Vec<&str> = exact.iter().map(|s| s.name.as_str()).collect();
1062        assert_eq!(exact_names, vec!["batch"], "exact --name must not leak substrings");
1063
1064        // Opt-in substring still reaches the noisy matches.
1065        let substr = symbols(&dir, None, Some("batch"), false, true).unwrap();
1066        assert!(substr.iter().any(|s| s.name == "batch"));
1067        assert!(substr.iter().any(|s| s.name == "test_create_batch"));
1068        assert!(substr.iter().any(|s| s.name == "update_batch_name"));
1069
1070        std::fs::remove_dir_all(&dir).ok();
1071    }
1072
1073    // ---- definition / definition_at ----
1074
1075    #[test]
1076    fn definition_finds_exact_name() {
1077        let dir = std::env::temp_dir().join(format!("grove_def_test_{}", std::process::id()));
1078        std::fs::create_dir_all(&dir).unwrap();
1079        std::fs::write(dir.join("lib.rs"), "fn target() {}\nfn target_helper() {}\n").unwrap();
1080
1081        let defs = definition(&dir, "target").unwrap();
1082        assert_eq!(defs.len(), 1, "exact match only, not the substring `target_helper`");
1083        assert_eq!(defs[0].name, "target");
1084
1085        std::fs::remove_dir_all(&dir).ok();
1086    }
1087
1088    #[test]
1089    fn definition_at_resolves_use_site_to_def() {
1090        let dir = std::env::temp_dir().join(format!("grove_defat_test_{}", std::process::id()));
1091        std::fs::create_dir_all(&dir).unwrap();
1092        let file = dir.join("lib.rs");
1093        std::fs::write(&file, "fn target() {}\nfn caller() {\n    target();\n}\n").unwrap();
1094
1095        let (name, defs) = definition_at(&file, 2, 4, &dir).unwrap();
1096        assert_eq!(name, "target");
1097        assert_eq!(defs.len(), 1);
1098        assert_eq!(defs[0].line, 1, "def is on line 1 (1-based)");
1099
1100        // No identifier at an empty position errors with context.
1101        let err = definition_at(&file, 1, 0, &dir).err();
1102        assert!(err.is_none() || err.unwrap().to_string().contains("no identifier"));
1103
1104        std::fs::remove_dir_all(&dir).ok();
1105    }
1106
1107    // ---- source error paths ----
1108
1109    #[test]
1110    fn source_rejects_malformed_id() {
1111        let err = source("rust:src/lib.rs", None).unwrap_err();
1112        assert!(err.to_string().contains("symbol id must look like"), "got: {err}");
1113    }
1114
1115    #[test]
1116    fn source_errors_when_name_absent() {
1117        let path = write_temp("absent", DUP);
1118        let err = source(path.to_str().unwrap(), Some("does_not_exist")).unwrap_err();
1119        assert!(err.to_string().contains("no definition named"), "got: {err}");
1120        std::fs::remove_file(&path).ok();
1121    }
1122
1123    // ---- map ----
1124
1125    #[test]
1126    fn map_returns_definitions_with_references() {
1127        let dir = std::env::temp_dir().join(format!("grove_map_test_{}", std::process::id()));
1128        std::fs::create_dir_all(&dir).unwrap();
1129        std::fs::write(
1130            dir.join("lib.rs"),
1131            "fn helper() {}\nfn main() {\n    helper();\n}\n",
1132        )
1133        .unwrap();
1134
1135        let maps = map(&dir, None, None, false).unwrap();
1136        assert_eq!(maps.len(), 1, "one file");
1137        let fm = &maps[0];
1138        assert!(fm.file.ends_with("lib.rs"), "file is lib.rs, got {}", fm.file);
1139
1140        // Two definitions: helper and main.
1141        assert_eq!(fm.entries.len(), 2, "two definitions");
1142        let helper = fm.entries.iter().find(|e| e.name == "helper").unwrap();
1143        let main_entry = fm.entries.iter().find(|e| e.name == "main").unwrap();
1144
1145        // helper has no outgoing references (it doesn't call anything).
1146        assert!(helper.references.is_empty(), "helper has no outgoing refs, got {:?}", helper.references);
1147
1148        // main references helper.
1149        assert_eq!(main_entry.references, vec!["helper"], "main references helper");
1150
1151        std::fs::remove_dir_all(&dir).ok();
1152    }
1153
1154    #[test]
1155    fn map_filters_by_kind_and_name() {
1156        let dir = std::env::temp_dir().join(format!("grove_map_filter_test_{}", std::process::id()));
1157        std::fs::create_dir_all(&dir).unwrap();
1158        std::fs::write(
1159            dir.join("lib.rs"),
1160            "struct S;\nimpl S {\n    fn m(&self) {\n        helper();\n    }\n}\nfn helper() {}\n",
1161        )
1162        .unwrap();
1163
1164        // Filter by kind: "function" only (Rust tags free functions as "function",
1165        // methods as "method").
1166        let maps = map(&dir, Some("function"), None, false).unwrap();
1167        let entries = &maps[0].entries;
1168        assert!(entries.iter().all(|e| e.kind == "function"), "all entries are functions, got {:?}", entries);
1169        assert!(entries.iter().any(|e| e.name == "helper"), "helper is a function");
1170        // m is a method, not a function — excluded by the kind filter.
1171        assert!(!entries.iter().any(|e| e.name == "m"), "m is a method, not a function");
1172
1173        // Filter by name: exact by default (issue #37), substring via name_contains.
1174        let exact = map(&dir, None, Some("helper"), false).unwrap();
1175        let entries = &exact[0].entries;
1176        assert!(entries.iter().any(|e| e.name == "helper"), "exact 'helper' matches");
1177        assert!(!entries.iter().any(|e| e.name == "S"), "S does not match 'helper'");
1178        // Substring opt-in: "help" reaches `helper`; exact mode does not (and
1179        // yields no file map at all, since no def is named exactly `help`).
1180        let substr = map(&dir, None, Some("help"), true).unwrap();
1181        assert!(
1182            substr.iter().flat_map(|fm| fm.entries.iter()).any(|e| e.name == "helper"),
1183            "helper matches 'help' substring"
1184        );
1185        let not_substr = map(&dir, None, Some("help"), false).unwrap();
1186        assert!(
1187            not_substr.iter().flat_map(|fm| fm.entries.iter()).all(|e| e.name != "helper"),
1188            "exact 'help' must not match 'helper'"
1189        );
1190
1191        std::fs::remove_dir_all(&dir).ok();
1192    }
1193
1194    #[test]
1195    fn map_excludes_self_references() {
1196        let dir = std::env::temp_dir().join(format!("grove_map_selfref_test_{}", std::process::id()));
1197        std::fs::create_dir_all(&dir).unwrap();
1198        // Recursive function: fn fib(n) { fib(n-1) }
1199        std::fs::write(
1200            dir.join("lib.rs"),
1201            "fn fib(n: i32) -> i32 {\n    fib(n - 1)\n}\n",
1202        )
1203        .unwrap();
1204
1205        let maps = map(&dir, None, None, false).unwrap();
1206        let fib = &maps[0].entries[0];
1207        assert_eq!(fib.name, "fib");
1208        assert!(fib.references.is_empty(), "self-reference is excluded, got {:?}", fib.references);
1209
1210        std::fs::remove_dir_all(&dir).ok();
1211    }
1212
1213    #[test]
1214    fn map_attributes_refs_to_innermost_definition() {
1215        // A reference inside a nested function should belong to the inner function,
1216        // not the outer one.
1217        let dir = std::env::temp_dir().join(format!("grove_map_nesting_test_{}", std::process::id()));
1218        std::fs::create_dir_all(&dir).unwrap();
1219        // Rust doesn't have nested named functions, but methods in impl blocks are
1220        // a similar nesting pattern. The reference to `helper` inside `m` should
1221        // belong to `m`, not to `S`.
1222        std::fs::write(
1223            dir.join("lib.rs"),
1224            "fn helper() {}\nstruct S;\nimpl S {\n    fn m(&self) {\n        helper();\n    }\n}\n",
1225        )
1226        .unwrap();
1227
1228        let maps = map(&dir, None, None, false).unwrap();
1229        let entries = &maps[0].entries;
1230
1231        let _s_entry = entries.iter().find(|e| e.name == "S").unwrap();
1232        // S's definition (struct) shouldn't reference helper directly —
1233        // helper is inside m, not inside S's struct body.
1234        // However, the impl block is a container, so the tags query might
1235        // attribute the reference to S. What matters is that m references helper.
1236        let m_entry = entries.iter().find(|e| e.name == "m").unwrap();
1237        assert!(m_entry.references.contains(&"helper".to_string()),
1238            "m references helper, got {:?}", m_entry.references);
1239
1240        std::fs::remove_dir_all(&dir).ok();
1241    }
1242
1243    #[test]
1244    fn map_across_multiple_files() {
1245        let dir = std::env::temp_dir().join(format!("grove_map_multi_test_{}", std::process::id()));
1246        std::fs::create_dir_all(&dir).unwrap();
1247        std::fs::write(dir.join("a.rs"), "fn alpha() {}\nfn call_beta() {\n    beta();\n}\n").unwrap();
1248        std::fs::write(dir.join("b.rs"), "fn beta() {}\nfn call_alpha() {\n    alpha();\n}\n").unwrap();
1249
1250        let maps = map(&dir, None, None, false).unwrap();
1251        assert!(maps.len() >= 2, "should have entries from both files, got {} files", maps.len());
1252
1253        // Each file should have its own definitions with references.
1254        let a_map = maps.iter().find(|m| m.file.contains("a.rs")).unwrap();
1255        let call_beta = a_map.entries.iter().find(|e| e.name == "call_beta").unwrap();
1256        assert!(call_beta.references.contains(&"beta".to_string()),
1257            "call_beta references beta, got {:?}", call_beta.references);
1258
1259        std::fs::remove_dir_all(&dir).ok();
1260    }
1261
1262    #[test]
1263    fn import_candidates_dotted_package_absolute() {
1264        let got = import_candidate_paths(
1265            "dotted_package",
1266            "pkg.util",
1267            Path::new("/proj/main.py"),
1268            Path::new("/proj"),
1269        );
1270        assert_eq!(
1271            got,
1272            vec![
1273                PathBuf::from("/proj/pkg/util.py"),
1274                PathBuf::from("/proj/pkg/util/__init__.py"),
1275            ]
1276        );
1277    }
1278
1279    #[test]
1280    fn import_candidates_dotted_package_relative_dots() {
1281        let file = Path::new("/proj/pkg/sibling.py");
1282        // One dot = the file's own package directory.
1283        assert_eq!(
1284            import_candidate_paths("dotted_package", ".util", file, Path::new("/proj")),
1285            vec![
1286                PathBuf::from("/proj/pkg/util.py"),
1287                PathBuf::from("/proj/pkg/util/__init__.py"),
1288            ]
1289        );
1290        // Two dots climb one further up.
1291        assert_eq!(
1292            import_candidate_paths("dotted_package", "..util", file, Path::new("/proj"))[0],
1293            PathBuf::from("/proj/util.py")
1294        );
1295    }
1296
1297    #[test]
1298    fn import_candidates_relative_path_js() {
1299        let file = Path::new("/proj/src/app.js");
1300        assert_eq!(
1301            import_candidate_paths("relative_path", "./calc", file, Path::new("/proj")),
1302            vec![
1303                PathBuf::from("/proj/src/calc.js"),
1304                PathBuf::from("/proj/src/calc.jsx"),
1305                PathBuf::from("/proj/src/calc/index.js"),
1306            ]
1307        );
1308        // `..` is normalized lexically.
1309        assert_eq!(
1310            import_candidate_paths("relative_path", "../lib/calc", file, Path::new("/proj"))[0],
1311            PathBuf::from("/proj/lib/calc.js")
1312        );
1313        // Bare specifiers (package imports) are not chased to a file.
1314        assert!(import_candidate_paths("relative_path", "react", file, Path::new("/proj")).is_empty());
1315    }
1316
1317    #[test]
1318    fn import_candidates_unknown_strategy_is_empty() {
1319        assert!(import_candidate_paths("nope", "x", Path::new("/p/a.py"), Path::new("/p")).is_empty());
1320    }
1321
1322    #[test]
1323    fn normalize_resolves_dot_and_dotdot() {
1324        assert_eq!(normalize(Path::new("/a/b/../util")), PathBuf::from("/a/util"));
1325        assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
1326    }
1327}