Skip to main content

gonzalo_graph/
store.rs

1//! Storage and structural queries over an assembled view's slices.
2//!
3//! Slices are path-agnostic (ADR 0012); the store keys each slice by the path
4//! it was assembled under, so location-bearing queries return [`Located`]
5//! results while the stored [`Symbol`]/[`Reference`] stay path-free.
6
7use crate::builder::Language;
8use crate::model::{
9    CodeGraph, FileSummary, FromScope, Import, Located, Page, RankedSymbol, Ranking, RefKind,
10    Reference, Symbol, SymbolFilter, SymbolKind, ViewOverview,
11};
12use std::collections::{BTreeMap, BTreeSet, VecDeque};
13
14/// The language bucket for `path`, by extension. Unrecognized or extensionless
15/// paths bucket under `"unknown"` so counts always sum to the symbol total.
16fn language_of(path: &str) -> &'static str {
17    std::path::Path::new(path)
18        .extension()
19        .and_then(|e| e.to_str())
20        .and_then(Language::from_extension)
21        .map_or("unknown", Language::as_str)
22}
23
24/// Structural queries over an assembled view (a set of path-keyed slices).
25pub trait GraphStore: Send + Sync {
26    /// Insert (or replace) the slice assembled at `path`. Re-inserting the same
27    /// path overwrites — slices are content-addressed and write-if-absent, never
28    /// appended, so this cannot duplicate a file's symbols.
29    fn insert(&mut self, path: &str, graph: CodeGraph);
30    /// Symbols defined in the slice at `path`.
31    fn symbols_in_file(&self, path: &str) -> Vec<Symbol>;
32    /// Definitions matching `name`, each with the path it was found under (there
33    /// may be several — names are unresolved).
34    fn definitions(&self, name: &str) -> Vec<Located<Symbol>>;
35    /// References whose target name is `name`, each with its path.
36    fn references_to(&self, name: &str) -> Vec<Located<Reference>>;
37    /// Names the slice at `path` brings into scope.
38    ///
39    /// Required rather than defaulted to empty on purpose: a backend that
40    /// silently returned nothing would leave import-aware resolution quietly
41    /// doing nothing while every other query looked right (#252).
42    fn imports_in_file(&self, path: &str) -> Vec<Import>;
43    /// Distinct enclosing-function names that reference `name`.
44    fn callers_of(&self, name: &str) -> Vec<String>;
45    /// Distinct names referenced from within `name` (the inverse of
46    /// [`callers_of`](Self::callers_of)), sorted.
47    fn callees(&self, name: &str) -> Vec<String>;
48
49    /// Distinct enclosing names that reference `name`, each with what that name
50    /// *is* — a function, or a module-level binding (#268).
51    ///
52    /// [`callers_of`](Self::callers_of) folds the two together. Separating them
53    /// is what lets a consumer ask the narrower question: "which functions call
54    /// this" and "which module-level declarations mention this" are different,
55    /// and attributing module-level calls would otherwise have widened `from`
56    /// silently.
57    ///
58    /// Defaulted over [`references_to`](Self::references_to) rather than left to
59    /// each backend, so no impl can quietly return nothing.
60    fn callers_scoped(&self, name: &str) -> Vec<(String, FromScope)> {
61        let mut out: Vec<(String, FromScope)> = self
62            .references_to(name)
63            .into_iter()
64            .filter(|located| located.item.kind != RefKind::Value)
65            .filter_map(|located| {
66                let scope = located.item.from_scope;
67                located.item.from.map(|from| (from, scope))
68            })
69            .collect();
70        out.sort();
71        out.dedup();
72        out
73    }
74    /// Every symbol in the view, each with its path (used for whole-graph
75    /// operations like diffing).
76    fn all_symbols(&self) -> Vec<Located<Symbol>>;
77    /// Every reference in the view, each with its path.
78    fn all_references(&self) -> Vec<Located<Reference>>;
79
80    /// The transitive closure of callers: every symbol that could be affected if
81    /// `name` changes, reached by walking [`callers_of`](Self::callers_of)
82    /// breadth-first. Runs server-side (never ships the whole graph); the seed
83    /// `name` is excluded, and cycles terminate via a visited set. Returned
84    /// sorted.
85    fn impact(&self, name: &str) -> Vec<String> {
86        let mut visited = BTreeSet::from([name.to_string()]);
87        let mut queue: VecDeque<String> = VecDeque::new();
88        for caller in self.callers_of(name) {
89            if visited.insert(caller.clone()) {
90                queue.push_back(caller);
91            }
92        }
93        while let Some(current) = queue.pop_front() {
94            for caller in self.callers_of(&current) {
95                if visited.insert(caller.clone()) {
96                    queue.push_back(caller);
97                }
98            }
99        }
100        visited.remove(name);
101        visited.into_iter().collect()
102    }
103
104    /// The aggregate shape of the whole view: counts, a breakdown by kind and
105    /// by language, and the `largest` files by symbol count.
106    ///
107    /// Answers "what is in this view" without the caller having to know a
108    /// symbol name first. Like [`impact`](Self::impact) this runs server-side
109    /// over the assembled graph; only the summary is returned.
110    fn overview(&self, largest: usize) -> ViewOverview {
111        let symbols = self.all_symbols();
112        let references = self.all_references();
113
114        let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
115        let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
116        let mut per_file: BTreeMap<&str, usize> = BTreeMap::new();
117        for located in &symbols {
118            *by_kind
119                .entry(located.item.kind.as_str().to_string())
120                .or_default() += 1;
121            *by_language
122                .entry(language_of(&located.path).to_string())
123                .or_default() += 1;
124            *per_file.entry(located.path.as_str()).or_default() += 1;
125        }
126
127        // A file contributes to the view if it holds symbols *or* references.
128        let files: BTreeSet<&str> = symbols
129            .iter()
130            .map(|l| l.path.as_str())
131            .chain(references.iter().map(|l| l.path.as_str()))
132            .collect();
133
134        // Descending by symbol count, then by path so ties are deterministic.
135        let mut largest_files: Vec<FileSummary> = per_file
136            .into_iter()
137            .map(|(path, symbols)| FileSummary {
138                path: path.to_string(),
139                symbols,
140            })
141            .collect();
142        largest_files.sort_by(|a, b| b.symbols.cmp(&a.symbols).then_with(|| a.path.cmp(&b.path)));
143        largest_files.truncate(largest);
144
145        ViewOverview {
146            files: files.len(),
147            symbols: symbols.len(),
148            references: references.len(),
149            by_kind,
150            by_language,
151            largest_files,
152        }
153    }
154
155    /// The top `limit` symbol names by `ranking`, descending.
156    ///
157    /// [`Ranking::Definitions`] is the ambiguity report: any name scoring above
158    /// 1 is defined in several places, so every name-matched traversal through
159    /// it merges unrelated subgraphs.
160    fn top(&self, ranking: Ranking, limit: usize) -> Page<RankedSymbol> {
161        let mut definitions: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
162        let symbols = self.all_symbols();
163        for located in &symbols {
164            definitions
165                .entry(located.item.name.as_str())
166                .or_default()
167                .insert(located.path.as_str());
168        }
169
170        // Call edges only, so these rankings agree with `callers`/`callees`. A
171        // fan-in of 2 beside a single caller is just confusing, and fan-out
172        // would otherwise count locals handed to a call as calls (#250).
173        let references: Vec<Located<Reference>> = self
174            .all_references()
175            .into_iter()
176            .filter(|located| located.item.kind != RefKind::Value)
177            .collect();
178        let mut scores: BTreeMap<&str, usize> = BTreeMap::new();
179        match ranking {
180            Ranking::Definitions => {
181                for (name, paths) in &definitions {
182                    scores.insert(name, paths.len());
183                }
184            }
185            Ranking::FanIn => {
186                for located in &references {
187                    *scores.entry(located.item.name.as_str()).or_default() += 1;
188                }
189            }
190            Ranking::FanOut => {
191                let mut callees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
192                for located in &references {
193                    if let Some(from) = located.item.from.as_deref() {
194                        callees.entry(from).or_default().insert(&located.item.name);
195                    }
196                }
197                for (name, called) in &callees {
198                    scores.insert(name, called.len());
199                }
200            }
201        }
202
203        // Descending by score, then by name so ties are deterministic.
204        let mut ranked: Vec<RankedSymbol> = scores
205            .into_iter()
206            .map(|(name, score)| RankedSymbol {
207                name: name.to_string(),
208                score,
209                paths: definitions
210                    .get(name)
211                    .map(|paths| paths.iter().map(|p| p.to_string()).collect())
212                    .unwrap_or_default(),
213            })
214            .collect();
215        ranked.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.name.cmp(&b.name)));
216        Page::new(ranked, limit)
217    }
218
219    /// Symbols matching `filter`, in path order, bounded by `limit`.
220    ///
221    /// The enumeration counterpart to [`definitions`](Self::definitions):
222    /// answers "what is in this crate" rather than "where is this name".
223    fn list(&self, filter: &SymbolFilter, limit: usize) -> Page<Located<Symbol>> {
224        let matched: Vec<Located<Symbol>> = self
225            .all_symbols()
226            .into_iter()
227            .filter(|located| filter.matches(located))
228            .collect();
229        Page::new(matched, limit)
230    }
231
232    /// Symbols with no inbound reference anywhere in the view — dead-code
233    /// *candidates*, in path then line order.
234    ///
235    /// With `exclude_tests` (the useful default) symbols inside a test scope are
236    /// dropped: members of a `mod tests` / `mod test` block, by line range, and
237    /// anything under a `tests/` directory. On gonzalo itself that filter is the
238    /// difference between 515 hits and 40 — without it the result is ~92% noise.
239    ///
240    /// **This is a heuristic, and its false positives are real.** It inherits
241    /// every limit of the name-matched extractor underneath:
242    ///
243    /// - A function used only as a value (`map_err(be)`, `and_then(f)`) is a
244    ///   path expression, not a call, so higher-order usage is invisible.
245    /// - Calls inside Rust macro arguments *are* now recorded (#216), but by a
246    ///   token-level heuristic: an identifier followed by a parenthesised token
247    ///   tree. A tuple-struct pattern such as `Some(_)` reads the same way, so
248    ///   macro-derived edges are slightly over-inclusive rather than missing.
249    /// - Names are unresolved: an unused `foo` is hidden by any other `foo` that
250    ///   is used.
251    /// - Conversely a reference from anywhere counts, including from tests and
252    ///   from the symbol itself, so recursive-only and test-only functions are
253    ///   *not* reported even though they may be dead.
254    ///
255    /// Treat every result as a lead to confirm, never as proof.
256    fn unreferenced(
257        &self,
258        filter: &SymbolFilter,
259        exclude_tests: bool,
260        limit: usize,
261    ) -> Page<Located<Symbol>> {
262        let referenced: BTreeSet<String> = self
263            .all_references()
264            .into_iter()
265            .map(|located| located.item.name)
266            .collect();
267
268        let symbols = self.all_symbols();
269        // Line ranges of `mod tests` blocks, per path, so members can be
270        // excluded by containment rather than by name.
271        let test_scopes: Vec<(String, usize, usize)> = symbols
272            .iter()
273            .filter(|l| l.item.kind == SymbolKind::Module && is_test_scope_name(&l.item.name))
274            .map(|l| (l.path.clone(), l.item.start_line, l.item.end_line))
275            .collect();
276
277        let in_test_scope = |located: &Located<Symbol>| {
278            in_tests_dir(&located.path)
279                || test_scopes.iter().any(|(path, start, end)| {
280                    *path == located.path
281                        && located.item.start_line >= *start
282                        && located.item.end_line <= *end
283                })
284        };
285
286        let mut matched: Vec<Located<Symbol>> = symbols
287            .into_iter()
288            .filter(|located| !referenced.contains(&located.item.name))
289            .filter(|located| filter.matches(located))
290            .filter(|located| !exclude_tests || !in_test_scope(located))
291            .collect();
292
293        matched.sort_by(|a, b| {
294            a.path
295                .cmp(&b.path)
296                .then_with(|| a.item.start_line.cmp(&b.item.start_line))
297        });
298        Page::new(matched, limit)
299    }
300}
301
302/// Whether a module name marks a test scope (Rust's `mod tests` convention).
303fn is_test_scope_name(name: &str) -> bool {
304    name == "tests" || name == "test"
305}
306
307/// Whether `path` lies under a `tests/` directory. Compares whole path
308/// components, so `contests/entry.rs` does not match.
309fn in_tests_dir(path: &str) -> bool {
310    path.split('/').any(|part| part == "tests")
311}
312
313/// An in-memory [`GraphStore`], one slice per path.
314#[derive(Debug, Default)]
315pub struct InMemoryGraphStore {
316    slices: BTreeMap<String, CodeGraph>,
317}
318
319impl InMemoryGraphStore {
320    pub fn new() -> Self {
321        Self::default()
322    }
323
324    /// The assembled slices, keyed by path.
325    pub fn slices(&self) -> &BTreeMap<String, CodeGraph> {
326        &self.slices
327    }
328}
329
330impl GraphStore for InMemoryGraphStore {
331    fn insert(&mut self, path: &str, graph: CodeGraph) {
332        self.slices.insert(path.to_string(), graph);
333    }
334
335    fn symbols_in_file(&self, path: &str) -> Vec<Symbol> {
336        self.slices
337            .get(path)
338            .map(|g| g.symbols.clone())
339            .unwrap_or_default()
340    }
341
342    fn imports_in_file(&self, path: &str) -> Vec<Import> {
343        self.slices
344            .get(path)
345            .map(|g| g.imports.clone())
346            .unwrap_or_default()
347    }
348
349    fn definitions(&self, name: &str) -> Vec<Located<Symbol>> {
350        self.slices
351            .iter()
352            .flat_map(|(path, g)| {
353                g.symbols
354                    .iter()
355                    .filter(|s| s.name == name)
356                    .map(move |s| Located {
357                        path: path.clone(),
358                        item: s.clone(),
359                    })
360            })
361            .collect()
362    }
363
364    fn references_to(&self, name: &str) -> Vec<Located<Reference>> {
365        self.slices
366            .iter()
367            .flat_map(|(path, g)| {
368                g.references
369                    .iter()
370                    .filter(|r| r.name == name)
371                    .map(move |r| Located {
372                        path: path.clone(),
373                        item: r.clone(),
374                    })
375            })
376            .collect()
377    }
378
379    fn callers_of(&self, name: &str) -> Vec<String> {
380        let mut callers: Vec<String> = self
381            .slices
382            .values()
383            .flat_map(|g| g.references.iter())
384            .filter(|r| r.name == name && r.kind != RefKind::Value)
385            .filter_map(|r| r.from.clone())
386            .collect();
387        callers.sort();
388        callers.dedup();
389        callers
390    }
391
392    fn callees(&self, name: &str) -> Vec<String> {
393        let mut callees: Vec<String> = self
394            .slices
395            .values()
396            .flat_map(|g| g.references.iter())
397            .filter(|r| r.from.as_deref() == Some(name) && r.kind != RefKind::Value)
398            .map(|r| r.name.clone())
399            .collect();
400        callees.sort();
401        callees.dedup();
402        callees
403    }
404
405    fn all_symbols(&self) -> Vec<Located<Symbol>> {
406        self.slices
407            .iter()
408            .flat_map(|(path, g)| {
409                g.symbols.iter().map(move |s| Located {
410                    path: path.clone(),
411                    item: s.clone(),
412                })
413            })
414            .collect()
415    }
416
417    fn all_references(&self) -> Vec<Located<Reference>> {
418        self.slices
419            .iter()
420            .flat_map(|(path, g)| {
421                g.references.iter().map(move |r| Located {
422                    path: path.clone(),
423                    item: r.clone(),
424                })
425            })
426            .collect()
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::builder::{build, build_rust};
434    use crate::model::{RefKind, SymbolKind};
435
436    const SRC: &str = r#"
437fn helper() {}
438fn a() { helper(); }
439fn b() { helper(); }
440"#;
441
442    fn store() -> InMemoryGraphStore {
443        let mut s = InMemoryGraphStore::new();
444        s.insert("lib.rs", build_rust(SRC));
445        s
446    }
447
448    #[test]
449    fn definitions_carry_their_path() {
450        let s = store();
451        let defs = s.definitions("helper");
452        assert_eq!(defs.len(), 1);
453        assert_eq!(defs[0].path, "lib.rs");
454        assert_eq!(defs[0].item.name, "helper");
455    }
456
457    #[test]
458    fn symbols_in_file_filters_by_path() {
459        let s = store();
460        assert!(s.symbols_in_file("lib.rs").iter().any(|sy| sy.name == "a"));
461        assert!(s.symbols_in_file("other.rs").is_empty());
462    }
463
464    #[test]
465    fn definitions_span_multiple_paths() {
466        let mut s = InMemoryGraphStore::new();
467        s.insert("a.rs", build_rust("fn dup() {}"));
468        s.insert("b.rs", build_rust("fn dup() {}"));
469        let mut paths: Vec<String> = s.definitions("dup").into_iter().map(|l| l.path).collect();
470        paths.sort();
471        assert_eq!(paths, vec!["a.rs".to_string(), "b.rs".to_string()]);
472    }
473
474    #[test]
475    fn reinserting_same_path_replaces_not_appends() {
476        let mut s = store();
477        // Re-assembling the same path must not duplicate its symbols.
478        s.insert("lib.rs", build_rust(SRC));
479        assert_eq!(s.definitions("helper").len(), 1);
480    }
481
482    #[test]
483    fn callers_of_dedups_and_sorts_across_slices() {
484        let s = store();
485        assert_eq!(
486            s.callers_of("helper"),
487            vec!["a".to_string(), "b".to_string()]
488        );
489    }
490
491    #[test]
492    fn references_to_counts_all_with_paths() {
493        let s = store();
494        let refs = s.references_to("helper");
495        assert_eq!(refs.len(), 2);
496        assert!(refs.iter().all(|r| r.path == "lib.rs"));
497    }
498
499    const CHAIN: &str = r#"
500fn leaf() {}
501fn mid() { leaf(); }
502fn top() { mid(); }
503fn other() { leaf(); }
504"#;
505
506    fn chain() -> InMemoryGraphStore {
507        let mut s = InMemoryGraphStore::new();
508        s.insert("chain.rs", build_rust(CHAIN));
509        s
510    }
511
512    #[test]
513    fn callees_lists_names_called_from_a_function() {
514        let s = chain();
515        assert_eq!(s.callees("mid"), vec!["leaf".to_string()]);
516        assert_eq!(s.callees("top"), vec!["mid".to_string()]);
517        assert!(s.callees("leaf").is_empty());
518    }
519
520    #[test]
521    fn callees_dedups_repeated_calls() {
522        let mut s = InMemoryGraphStore::new();
523        s.insert("d.rs", build_rust("fn f() { g(); g(); h(); }"));
524        assert_eq!(s.callees("f"), vec!["g".to_string(), "h".to_string()]);
525    }
526
527    #[test]
528    fn impact_is_the_transitive_caller_closure() {
529        let s = chain();
530        // Everything transitively affected if `leaf` changes: its callers mid &
531        // other, and mid's caller top.
532        assert_eq!(
533            s.impact("leaf"),
534            vec!["mid".to_string(), "other".to_string(), "top".to_string()]
535        );
536        assert_eq!(s.impact("mid"), vec!["top".to_string()]);
537        assert!(s.impact("top").is_empty());
538    }
539
540    #[test]
541    fn impact_excludes_the_seed_and_survives_cycles() {
542        let mut s = InMemoryGraphStore::new();
543        s.insert("cyc.rs", build_rust("fn a() { b(); } fn b() { a(); }"));
544        // a<->b mutually recurse; impact must terminate and not report the seed.
545        assert_eq!(s.impact("a"), vec!["b".to_string()]);
546        assert_eq!(s.impact("b"), vec!["a".to_string()]);
547    }
548
549    // ---- aggregate / structural queries (#214) ----------------------------
550
551    /// A small multi-path, multi-language view: `new` is defined in two crates
552    /// (the ambiguity #207 turns on), `helper` is called twice from one site,
553    /// and one file is deliberately larger than the others.
554    fn view() -> InMemoryGraphStore {
555        let mut s = InMemoryGraphStore::new();
556        s.insert(
557            "crates/a/src/lib.rs",
558            build_rust(
559                "struct A; struct A2; fn new() -> A { A } \
560                 fn run() { new(); helper(); helper(); }",
561            ),
562        );
563        s.insert("crates/b/src/lib.rs", build_rust("fn new() -> u8 { 0 }"));
564        s.insert(
565            "scripts/tool.py",
566            build(Language::Python, "def main():\n    pass\n"),
567        );
568        s
569    }
570
571    #[test]
572    fn overview_counts_files_symbols_and_references() {
573        let o = view().overview(10);
574        assert_eq!(o.files, 3);
575        assert_eq!(o.symbols, view().all_symbols().len());
576        assert_eq!(o.references, view().all_references().len());
577    }
578
579    #[test]
580    fn overview_breaks_down_by_kind() {
581        let o = view().overview(10);
582        // Two structs in a/lib.rs; functions in every file.
583        assert_eq!(o.by_kind.get("struct"), Some(&2));
584        assert!(o.by_kind.get("function").is_some_and(|n| *n >= 3));
585    }
586
587    #[test]
588    fn overview_breaks_down_by_language_from_the_path_extension() {
589        let o = view().overview(10);
590        assert!(o.by_language.contains_key("rust"));
591        assert!(o.by_language.contains_key("python"));
592        // Every symbol lands in exactly one language bucket.
593        assert_eq!(o.by_language.values().sum::<usize>(), o.symbols);
594    }
595
596    #[test]
597    fn overview_buckets_unrecognized_extensions_as_unknown() {
598        let mut s = InMemoryGraphStore::new();
599        s.insert("build.zzz", build_rust("fn f() {}"));
600        let o = s.overview(10);
601        assert_eq!(o.by_language.get("unknown"), Some(&1));
602        assert_eq!(o.by_language.values().sum::<usize>(), o.symbols);
603    }
604
605    #[test]
606    fn overview_ranks_largest_files_by_symbol_count() {
607        let o = view().overview(10);
608        assert_eq!(o.largest_files[0].path, "crates/a/src/lib.rs");
609        assert!(o.largest_files[0].symbols >= o.largest_files[1].symbols);
610    }
611
612    #[test]
613    fn overview_bounds_largest_files_to_the_requested_limit() {
614        let o = view().overview(1);
615        assert_eq!(o.largest_files.len(), 1);
616        // The count itself is never truncated, only the per-file listing.
617        assert_eq!(o.files, 3);
618    }
619
620    #[test]
621    fn top_by_definitions_surfaces_names_defined_in_several_paths() {
622        let page = view().top(Ranking::Definitions, 10);
623        let new = page
624            .items
625            .iter()
626            .find(|r| r.name == "new")
627            .expect("`new` is defined twice");
628        assert_eq!(new.score, 2);
629        assert_eq!(
630            new.paths,
631            vec![
632                "crates/a/src/lib.rs".to_string(),
633                "crates/b/src/lib.rs".to_string()
634            ]
635        );
636    }
637
638    #[test]
639    fn top_by_fan_in_ranks_by_reference_count() {
640        let page = view().top(Ranking::FanIn, 10);
641        let helper = page
642            .items
643            .iter()
644            .find(|r| r.name == "helper")
645            .expect("`helper` is called twice");
646        assert_eq!(helper.score, 2);
647    }
648
649    #[test]
650    fn top_by_fan_out_ranks_by_distinct_callees() {
651        let page = view().top(Ranking::FanOut, 10);
652        let run = page
653            .items
654            .iter()
655            .find(|r| r.name == "run")
656            .expect("`run` calls new and helper");
657        assert_eq!(run.score, 2);
658    }
659
660    #[test]
661    fn top_is_ordered_by_descending_score() {
662        let page = view().top(Ranking::FanIn, 10);
663        let scores: Vec<usize> = page.items.iter().map(|r| r.score).collect();
664        let mut sorted = scores.clone();
665        sorted.sort_by(|a, b| b.cmp(a));
666        assert_eq!(scores, sorted);
667    }
668
669    #[test]
670    fn top_bounds_results_and_reports_truncation() {
671        let page = view().top(Ranking::FanIn, 1);
672        assert_eq!(page.items.len(), 1);
673        assert!(page.truncated);
674        assert!(page.total > 1);
675    }
676
677    #[test]
678    fn list_filters_by_path_prefix() {
679        let page = view().list(&SymbolFilter::default().path_prefix("crates/b"), 100);
680        assert!(!page.items.is_empty());
681        assert!(page.items.iter().all(|l| l.path == "crates/b/src/lib.rs"));
682    }
683
684    #[test]
685    fn list_filters_by_kind() {
686        let page = view().list(&SymbolFilter::default().kind(SymbolKind::Struct), 100);
687        assert_eq!(page.items.len(), 2);
688        assert!(page.items.iter().all(|l| l.item.kind == SymbolKind::Struct));
689    }
690
691    #[test]
692    fn list_filters_by_name_substring() {
693        let page = view().list(&SymbolFilter::default().name_contains("ru"), 100);
694        assert!(page.items.iter().any(|l| l.item.name == "run"));
695        assert!(page.items.iter().all(|l| l.item.name.contains("ru")));
696    }
697
698    #[test]
699    fn list_combines_filters_conjunctively() {
700        let page = view().list(
701            &SymbolFilter::default()
702                .path_prefix("crates/a")
703                .kind(SymbolKind::Struct),
704            100,
705        );
706        assert_eq!(page.items.len(), 2);
707        assert!(
708            page.items
709                .iter()
710                .all(|l| l.path.starts_with("crates/a") && l.item.kind == SymbolKind::Struct)
711        );
712    }
713
714    #[test]
715    fn list_bounds_results_and_reports_truncation() {
716        let page = view().list(&SymbolFilter::default(), 1);
717        assert_eq!(page.items.len(), 1);
718        assert!(page.truncated);
719        assert_eq!(page.total, view().all_symbols().len());
720    }
721
722    #[test]
723    fn list_without_filters_returns_everything_untruncated() {
724        let page = view().list(&SymbolFilter::default(), 1000);
725        assert!(!page.truncated);
726        assert_eq!(page.items.len(), view().all_symbols().len());
727    }
728
729    // ---- unreferenced / dead-code candidates (#214) -----------------------
730
731    /// A view with one genuinely-unused production function (`orphan`), one
732    /// used one (`used`), and a `mod tests` whose members are unused outside
733    /// the module — the 92%-noise population the filter exists to remove.
734    fn dead() -> InMemoryGraphStore {
735        let mut s = InMemoryGraphStore::new();
736        s.insert(
737            "src/lib.rs",
738            build_rust(
739                "fn used() {}\n\
740                 fn caller() { used(); }\n\
741                 fn orphan() {}\n\
742                 #[cfg(test)]\n\
743                 mod tests {\n    \
744                     fn t_helper() {}\n    \
745                     fn t_only() {}\n\
746                 }\n",
747            ),
748        );
749        s
750    }
751
752    fn names(page: &Page<Located<Symbol>>) -> Vec<&str> {
753        page.items.iter().map(|l| l.item.name.as_str()).collect()
754    }
755
756    #[test]
757    fn unreferenced_finds_symbols_with_no_inbound_reference() {
758        let page = dead().unreferenced(&SymbolFilter::default(), true, 100);
759        assert!(names(&page).contains(&"orphan"));
760        assert!(!names(&page).contains(&"used"));
761    }
762
763    #[test]
764    fn unreferenced_excludes_mod_tests_members_by_default() {
765        let page = dead().unreferenced(&SymbolFilter::default(), true, 100);
766        // Both live inside `mod tests`' line range, so neither is a candidate.
767        assert!(!names(&page).contains(&"t_helper"));
768        assert!(!names(&page).contains(&"t_only"));
769        // The module symbol itself is a test scope, not a candidate.
770        assert!(!names(&page).contains(&"tests"));
771    }
772
773    #[test]
774    fn unreferenced_keeps_test_members_when_not_excluding() {
775        let page = dead().unreferenced(&SymbolFilter::default(), false, 100);
776        assert!(names(&page).contains(&"t_only"));
777        assert!(names(&page).contains(&"orphan"));
778    }
779
780    #[test]
781    fn unreferenced_excludes_files_under_a_tests_directory() {
782        let mut s = InMemoryGraphStore::new();
783        s.insert(
784            "tests/it.rs",
785            build_rust("fn only_in_integration_test() {}"),
786        );
787        s.insert("src/lib.rs", build_rust("fn orphan() {}"));
788        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
789        assert_eq!(names(&page), vec!["orphan"]);
790    }
791
792    #[test]
793    fn unreferenced_does_not_treat_a_tests_substring_as_a_directory() {
794        let mut s = InMemoryGraphStore::new();
795        // `contests` merely contains "tests"; it is not a test directory.
796        s.insert("contests/entry.rs", build_rust("fn orphan() {}"));
797        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
798        assert_eq!(names(&page), vec!["orphan"]);
799    }
800
801    #[test]
802    fn unreferenced_counts_a_reference_from_anywhere_including_tests() {
803        let mut s = InMemoryGraphStore::new();
804        // `prod` is called only from a test — conservatively still "referenced",
805        // so it is never reported as dead.
806        s.insert(
807            "src/lib.rs",
808            build_rust(
809                "fn prod() {}\n\
810                 #[cfg(test)]\n\
811                 mod tests {\n    \
812                     fn t() { prod(); }\n\
813                 }\n",
814            ),
815        );
816        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
817        assert!(!names(&page).contains(&"prod"));
818    }
819
820    #[test]
821    fn unreferenced_does_not_flag_symbols_called_inside_macros() {
822        let mut s = InMemoryGraphStore::new();
823        // Was a pinned false positive: macro bodies parse as token trees, so
824        // the call to `f` went unrecorded and `f` was reported as dead. #216
825        // taught the extractor to read calls out of macro arguments, and this
826        // test flipped with it.
827        s.insert(
828            "src/lib.rs",
829            build_rust("fn f() -> u8 { 0 }\nfn g() { assert_eq!(f(), 0); }\n"),
830        );
831        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
832        assert!(!names(&page).contains(&"f"), "f is called from g");
833    }
834
835    #[test]
836    fn unreferenced_does_not_flag_recursive_functions() {
837        let mut s = InMemoryGraphStore::new();
838        // A self-reference counts, so a recursive-only function is a false
839        // negative rather than a confidently-wrong dead-code report.
840        s.insert("src/lib.rs", build_rust("fn recur() { recur(); }"));
841        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
842        assert!(!names(&page).contains(&"recur"));
843    }
844
845    #[test]
846    fn unreferenced_applies_the_symbol_filter() {
847        let page =
848            dead().unreferenced(&SymbolFilter::default().kind(SymbolKind::Struct), true, 100);
849        assert!(page.items.is_empty());
850    }
851
852    #[test]
853    fn unreferenced_results_carry_their_path() {
854        let page = dead().unreferenced(&SymbolFilter::default(), true, 100);
855        assert!(page.items.iter().all(|l| l.path == "src/lib.rs"));
856    }
857
858    #[test]
859    fn unreferenced_bounds_results_and_reports_truncation() {
860        let mut s = InMemoryGraphStore::new();
861        s.insert("src/lib.rs", build_rust("fn a() {} fn b() {} fn c() {}"));
862        let page = s.unreferenced(&SymbolFilter::default(), true, 2);
863        assert_eq!(page.items.len(), 2);
864        assert!(page.truncated);
865        assert_eq!(page.total, 3);
866    }
867
868    #[test]
869    fn unreferenced_is_ordered_by_path_then_line() {
870        let mut s = InMemoryGraphStore::new();
871        s.insert("src/b.rs", build_rust("fn z() {}"));
872        s.insert("src/a.rs", build_rust("fn y() {}\nfn x() {}"));
873        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
874        let seen: Vec<(&str, &str)> = page
875            .items
876            .iter()
877            .map(|l| (l.path.as_str(), l.item.name.as_str()))
878            .collect();
879        assert_eq!(
880            seen,
881            vec![("src/a.rs", "y"), ("src/a.rs", "x"), ("src/b.rs", "z")]
882        );
883    }
884
885    // ---- functions passed as values (#250) --------------------------------
886
887    /// `helper` is never called, only handed to `register`.
888    fn passed_as_a_value() -> InMemoryGraphStore {
889        let mut s = InMemoryGraphStore::new();
890        s.insert(
891            "src/lib.rs",
892            build_rust("fn helper() {}\nfn g() { register(helper); }\n"),
893        );
894        s
895    }
896
897    #[test]
898    fn unreferenced_does_not_flag_a_function_only_passed_as_a_value() {
899        // The documented main false positive of this tool: a live callback is
900        // a path expression, not a call, so it recorded no edge and was
901        // reported as deletable (#250).
902        let s = passed_as_a_value();
903        let page = s.unreferenced(&SymbolFilter::default(), true, 100);
904        assert!(
905            !names(&page).contains(&"helper"),
906            "helper is passed to register: {:?}",
907            names(&page)
908        );
909    }
910
911    #[test]
912    fn callers_of_ignores_a_function_passed_as_a_value() {
913        // Passing a function is not calling it. Letting a value reference into
914        // the call graph would trade one wrong answer for another.
915        let s = passed_as_a_value();
916        assert!(
917            s.callers_of("helper").is_empty(),
918            "{:?}",
919            s.callers_of("helper")
920        );
921    }
922
923    #[test]
924    fn callees_ignores_a_function_passed_as_a_value() {
925        let s = passed_as_a_value();
926        assert_eq!(s.callees("g"), vec!["register".to_string()]);
927    }
928
929    #[test]
930    fn references_to_still_reports_a_value_reference() {
931        // The raw query stays complete — the filtering belongs to the call-graph
932        // views, not to the reference index.
933        let s = passed_as_a_value();
934        let refs = s.references_to("helper");
935        assert_eq!(refs.len(), 1);
936        assert_eq!(refs[0].item.kind, RefKind::Value);
937    }
938
939    #[test]
940    fn fan_in_and_fan_out_stay_call_graph_metrics() {
941        // `top` has to agree with `callers`/`callees`: a fan-in of 2 next to one
942        // caller is just confusing, and counting locals handed to a call would
943        // inflate fan-out with things that are not calls at all (#250).
944        let mut s = InMemoryGraphStore::new();
945        s.insert(
946            "src/lib.rs",
947            build_rust("fn helper() {}\nfn g(x: u32) { register(helper); take(x); }\n"),
948        );
949
950        let fan_in = s.top(Ranking::FanIn, 50);
951        let helper_score = fan_in
952            .items
953            .iter()
954            .find(|r| r.name == "helper")
955            .map(|r| r.score);
956        assert_eq!(helper_score, None, "helper is never called: {fan_in:?}");
957
958        let fan_out = s.top(Ranking::FanOut, 50);
959        let g_score = fan_out
960            .items
961            .iter()
962            .find(|r| r.name == "g")
963            .map(|r| r.score);
964        assert_eq!(g_score, Some(2), "g calls register and take: {fan_out:?}");
965    }
966}