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