Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/autonom/src/feeder.rs

//! **The source-scanning feeder** (feature `scan`) — the missing runnable seam.
//!
//! [`crate::discover`]'s enumerators are PURE: they turn pre-computed facts
//! ([`SymbolRow`] + [`CallEdge`]) into a [`Surface`] and difference it. But *who
//! produces those facts?* In nornir the feeder reads a live symbol graph (built
//! from a warehouse + facett introspection) — deps `autonom` deliberately cannot
//! take. So without a feeder, `autonom` could compute a gap but never *run* one.
//!
//! This module is that feeder, self-contained: walk a repo's `.rs` files, parse
//! each with `syn`, and emit
//!
//! * one [`SymbolRow`] per `fn` (free fn, method, trait-default) with a stable
//!   fully-qualified id and an honest `is_test` flag (a `#[test]`/`#[tokio::test]`
//!   fn, anything inside a `#[cfg(test)]` module, or any fn in an integration
//!   `tests/` dir — these are the ROOTS of the reachable closure, never surface),
//! * one [`CallEdge`] per resolved call site (`foo(..)`, `path::foo(..)`, and
//!   `x.foo()` method calls) — resolved by SIMPLE NAME to every defined fn of
//!   that name.
//!
//! Feeding those to [`unreached_functions`] yields exactly the functions **no
//! test can reach** — the "code that lacks tests" list the completeness gate is
//! built to burn down.
//!
//! ## Heuristic honesty
//! Call resolution is by simple name (last path/method segment), so it
//! *over-connects* on name collisions: two distinct `fn run()` in different
//! modules are linked to any `run()` call. That biases toward marking a fn
//! **covered**, i.e. the "needs tests" list under-reports rather than cries wolf
//! — the safe direction for a worklist a human then triages. A cross-crate `use`
//! alias or a fn only reached through a trait object may thus be missed; the list
//! is a high-signal starting point, not a proof of deadness.

use std::collections::BTreeMap;
use std::path::Path;

use anyhow::{Context, Result};

// Consume the fact types through autonom's OWN façade (`crate::…`), not
// `nornir_testmatrix::…` directly — the feeder dogfoods the re-export so the
// boundary can never be silently bypassed.
use crate::{CallEdge, SymbolRow};

/// The raw facts a scan produced: the fn symbols, the resolved call edges, and
/// how many files were read / skipped (a file `syn` can't parse is skipped, not
/// fatal — a scan of a large tree must not abort on one odd file).
#[derive(Debug, Default, Clone)]
pub struct ScanResult {
    /// One row per discovered `fn` (test fns included, flagged `is_test`).
    pub symbols: Vec<SymbolRow>,
    /// Resolved `caller_fqn → callee_fqn` edges (simple-name resolution).
    pub edges: Vec<CallEdge>,
    /// How many source files were parsed successfully.
    pub files_scanned: usize,
    /// How many files were skipped because `syn` could not parse them.
    pub files_skipped: usize,
}

/// An incremental scanner: feed it source strings (one per file) with a module
/// prefix, then [`finish`](Scanner::finish) to resolve calls into [`CallEdge`]s.
///
/// Kept separate from [`scan_repo`] so it is drivable from a string in a unit
/// test / bench with no filesystem (the bencher parses a generated blob).
#[derive(Default)]
pub struct Scanner {
    symbols: Vec<SymbolRow>,
    /// `(caller_fqn, callee_simple_name)` — resolved to edges in `finish`.
    raw_calls: Vec<(String, String)>,
    /// simple fn name → every fqn defined with that name.
    name_index: BTreeMap<String, Vec<String>>,
    files_scanned: usize,
    files_skipped: usize,
}

impl Scanner {
    pub fn new() -> Self {
        Self::default()
    }

    /// Parse one source blob under `module_prefix` (e.g. `"autonom::feeder"`),
    /// appending its fns + call sites. `file_is_test` marks every fn in the blob
    /// a test root (used for integration `tests/` files). Returns `Ok(())` and
    /// records a skip if the blob does not parse.
    pub fn add_source(&mut self, src: &str, module_prefix: &str, file_is_test: bool) {
        let file = match syn::parse_file(src) {
            Ok(f) => f,
            Err(_) => {
                self.files_skipped += 1;
                return;
            }
        };
        self.files_scanned += 1;
        let mut v = FnVisitor {
            module_path: Vec::new(),
            prefix: module_prefix.to_string(),
            test_ctx: vec![file_is_test],
            symbols: &mut self.symbols,
            raw_calls: &mut self.raw_calls,
            name_index: &mut self.name_index,
            current_fn: None,
        };
        syn::visit::Visit::visit_file(&mut v, &file);
    }

    /// Resolve every recorded call site (by simple name) to a [`CallEdge`] per
    /// matching defined fn, and return the finished [`ScanResult`]. Edges are
    /// deduped so a fn calling `foo()` twice yields one edge.
    pub fn finish(self) -> ScanResult {
        use std::collections::BTreeSet;
        let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
        let mut edges = Vec::new();
        for (caller, callee_simple) in &self.raw_calls {
            if let Some(fqns) = self.name_index.get(callee_simple) {
                for callee in fqns {
                    if callee == caller {
                        continue; // ignore self-recursion for reachability
                    }
                    if seen.insert((caller.clone(), callee.clone())) {
                        edges.push(CallEdge { caller: caller.clone(), callee: callee.clone() });
                    }
                }
            }
        }
        ScanResult {
            symbols: self.symbols,
            edges,
            files_scanned: self.files_scanned,
            files_skipped: self.files_skipped,
        }
    }
}

/// Scan every non-vendored `.rs` file under `root` and return the collected
/// facts. Skips `target/`, `.git/`, and any hidden directory. A file that fails
/// to parse is counted in [`ScanResult::files_skipped`], never fatal.
pub fn scan_repo(root: &Path) -> Result<ScanResult> {
    let root = root
        .canonicalize()
        .with_context(|| format!("resolve scan root {}", root.display()))?;
    let mut scanner = Scanner::new();
    for entry in walkdir::WalkDir::new(&root)
        .into_iter()
        .filter_entry(|e| !is_ignored_dir(e))
    {
        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("rs") {
            continue;
        }
        let src = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        let (prefix, file_is_test) = module_prefix(&root, path);
        scanner.add_source(&src, &prefix, file_is_test);
    }
    Ok(scanner.finish())
}

/// A directory walkdir should not descend into: `target`, `.git`, and any other
/// dot-directory (vendored / hidden trees are never the surface under test).
fn is_ignored_dir(entry: &walkdir::DirEntry) -> bool {
    if !entry.file_type().is_dir() {
        return false;
    }
    let name = entry.file_name().to_string_lossy();
    name == "target" || (name.starts_with('.') && name != "." && name != "..")
}

/// Derive a readable, reasonably-unique module prefix for `path` relative to
/// `root`, plus whether the file is an integration-test file (any `tests`
/// component). `crates/<c>/src/a/b.rs` → (`"<c>::a::b"`, false);
/// `<c>/tests/foo.rs` → (`"<c>::tests::foo"`, true). Filenames `lib`/`main`/`mod`
/// are dropped from the tail so a module isn't suffixed with them.
fn module_prefix(root: &Path, path: &Path) -> (String, bool) {
    let rel = path.strip_prefix(root).unwrap_or(path);
    let mut comps: Vec<String> = rel
        .components()
        .filter_map(|c| c.as_os_str().to_str().map(|s| s.to_string()))
        .collect();
    // Drop the `.rs` extension from the final component.
    if let Some(last) = comps.last_mut() {
        if let Some(stripped) = last.strip_suffix(".rs") {
            *last = stripped.to_string();
        }
    }
    let file_is_test = comps.iter().any(|c| c == "tests");
    // Reframe so the CRATE is the head: drop a leading `crates/` segment (a
    // workspace layout) and any structural `src` component. `crates/<c>/src/a.rs`
    // → `<c>::a`; `<c>/src/a.rs` → `<c>::a`; `crates/<c>/tests/t.rs` →
    // `<c>::tests::t`.
    if comps.first().map(String::as_str) == Some("crates") && comps.len() >= 2 {
        comps.remove(0);
    }
    comps.retain(|c| c != "src");
    // Drop a trailing structural filename so `foo/mod.rs` is just `foo`.
    if let Some(last) = comps.last() {
        if last == "mod" || last == "lib" || last == "main" {
            comps.pop();
        }
    }
    if comps.is_empty() {
        comps.push("crate".to_string());
    }
    (comps.join("::"), file_is_test)
}

/// The `syn` visitor: tracks the current module path + test context stack + the
/// enclosing fn (the caller for any call site), collecting [`SymbolRow`]s and raw
/// `(caller, callee_simple)` pairs.
struct FnVisitor<'a> {
    module_path: Vec<String>,
    prefix: String,
    /// Stack of "am I in a test context" — pushed at each mod / impl; a fn is a
    /// test root iff the top is `true` or it carries a test attribute.
    test_ctx: Vec<bool>,
    symbols: &'a mut Vec<SymbolRow>,
    raw_calls: &'a mut Vec<(String, String)>,
    name_index: &'a mut BTreeMap<String, Vec<String>>,
    current_fn: Option<String>,
}

impl<'a> FnVisitor<'a> {
    fn in_test_ctx(&self) -> bool {
        *self.test_ctx.last().unwrap_or(&false)
    }

    fn fqn(&self, name: &str) -> String {
        let mut parts = Vec::with_capacity(self.module_path.len() + 2);
        if !self.prefix.is_empty() {
            parts.push(self.prefix.clone());
        }
        parts.extend(self.module_path.iter().cloned());
        parts.push(name.to_string());
        parts.join("::")
    }

    /// Register a fn definition + descend into its body to collect call sites.
    fn record_fn(&mut self, name: &str, is_test_attr: bool, block: &syn::Block) {
        let fqn = self.fqn(name);
        let is_test = is_test_attr || self.in_test_ctx();
        self.symbols.push(SymbolRow { fqn: fqn.clone(), is_test, label: None });
        self.name_index.entry(name.to_string()).or_default().push(fqn.clone());
        let prev = self.current_fn.replace(fqn);
        syn::visit::Visit::visit_block(self, block);
        self.current_fn = prev;
    }
}

/// True if any attribute is a test marker: `#[test]`, `#[tokio::test]`,
/// `#[async_std::test]`, `#[test_case]`, … — i.e. a path whose LAST segment is
/// `test`, or a `#[cfg(test)]`.
fn attrs_mark_test(attrs: &[syn::Attribute]) -> bool {
    attrs.iter().any(|a| {
        let last = a.path().segments.last().map(|s| s.ident.to_string());
        if last.as_deref() == Some("test") {
            return true;
        }
        attr_is_cfg_test(a)
    })
}

/// True for `#[cfg(test)]` (also matches `#[cfg(all(test, ...))]` conservatively
/// by looking for a bare `test` token in the cfg predicate).
fn attr_is_cfg_test(attr: &syn::Attribute) -> bool {
    if attr.path().segments.last().map(|s| s.ident.to_string()).as_deref() != Some("cfg") {
        return false;
    }
    let mut found = false;
    // `parse_nested_meta` walks the predicate tokens; a `test` path anywhere in
    // it (cfg(test) / cfg(all(test, unix)) / …) marks a test-only item.
    let _ = attr.parse_nested_meta(|meta| {
        if meta.path.is_ident("test") {
            found = true;
        }
        // Recurse into nested lists like all(...) / any(...); ignore parse errors.
        let _ = meta.parse_nested_meta(|inner| {
            if inner.path.is_ident("test") {
                found = true;
            }
            Ok(())
        });
        Ok(())
    });
    found
}

/// The `Self` type name of an impl block, for the module segment (`impl:<Type>`).
fn impl_type_name(ty: &syn::Type) -> Option<String> {
    if let syn::Type::Path(tp) = ty {
        tp.path.segments.last().map(|s| s.ident.to_string())
    } else {
        None
    }
}

impl<'ast, 'a> syn::visit::Visit<'ast> for FnVisitor<'a> {
    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
        let is_test_mod = self.in_test_ctx() || attrs_mark_test(&node.attrs);
        self.module_path.push(node.ident.to_string());
        self.test_ctx.push(is_test_mod);
        syn::visit::visit_item_mod(self, node);
        self.test_ctx.pop();
        self.module_path.pop();
    }

    fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
        let is_test = self.in_test_ctx() || attrs_mark_test(&node.attrs);
        let seg = impl_type_name(&node.self_ty).unwrap_or_else(|| "impl".to_string());
        self.module_path.push(format!("impl:{seg}"));
        self.test_ctx.push(is_test);
        syn::visit::visit_item_impl(self, node);
        self.test_ctx.pop();
        self.module_path.pop();
    }

    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
        let is_test_attr = attrs_mark_test(&node.attrs);
        self.record_fn(&node.sig.ident.to_string(), is_test_attr, &node.block);
    }

    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
        let is_test_attr = attrs_mark_test(&node.attrs);
        self.record_fn(&node.sig.ident.to_string(), is_test_attr, &node.block);
    }

    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
        // Only default-bodied trait methods are real, callable fns.
        if let Some(block) = &node.default {
            let is_test_attr = attrs_mark_test(&node.attrs);
            self.record_fn(&node.sig.ident.to_string(), is_test_attr, block);
        }
    }

    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
        if let (Some(caller), syn::Expr::Path(p)) = (self.current_fn.clone(), &*node.func) {
            if let Some(seg) = p.path.segments.last() {
                self.raw_calls.push((caller, seg.ident.to_string()));
            }
        }
        syn::visit::visit_expr_call(self, node);
    }

    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
        if let Some(caller) = self.current_fn.clone() {
            self.raw_calls.push((caller, node.method.to_string()));
        }
        syn::visit::visit_expr_method_call(self, node);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{test_reachable, unreached_functions, SurfaceKind};

    #[test]
    fn scans_fns_and_flags_test_roots() {
        let src = r#"
            pub fn covered() { helper(); }
            fn helper() {}
            pub fn orphan() {}
            #[cfg(test)]
            mod tests {
                #[test]
                fn t() { super::covered(); }
            }
        "#;
        let mut s = Scanner::new();
        s.add_source(src, "demo", false);
        let scan = s.finish();

        // Four fns: covered, helper, orphan, and the test `t`.
        assert_eq!(scan.symbols.len(), 4, "{:?}", scan.symbols);
        let test_fns: Vec<_> = scan.symbols.iter().filter(|r| r.is_test).collect();
        assert_eq!(test_fns.len(), 1, "only `t` is a test root");
        assert!(test_fns[0].fqn.ends_with("::t"));
        assert_eq!(scan.files_scanned, 1);
        assert_eq!(scan.files_skipped, 0);
    }

    #[test]
    fn reachability_finds_the_orphan_as_needing_a_test() {
        // t → covered → helper ; orphan reached by nobody.
        let src = r#"
            pub fn covered() { helper(); }
            fn helper() {}
            pub fn orphan() {}
            #[cfg(test)]
            mod tests {
                #[test]
                fn t() { crate::covered(); }
            }
        "#;
        let mut s = Scanner::new();
        s.add_source(src, "demo", false);
        let scan = s.finish();

        let reach = test_reachable(&scan.symbols, &scan.edges);
        assert!(reach.iter().any(|f| f.ends_with("::covered")), "covered is test-reached");
        assert!(reach.iter().any(|f| f.ends_with("::helper")), "helper is transitively reached");
        assert!(!reach.iter().any(|f| f.ends_with("::orphan")), "orphan is unreached");

        let missing = unreached_functions(&scan.symbols, &scan.edges);
        assert_eq!(missing.len(), 1, "exactly the orphan: {missing:?}");
        assert_eq!(missing[0].kind, SurfaceKind::Function);
        assert!(missing[0].id.ends_with("::orphan"));
    }

    #[test]
    fn method_calls_resolve_and_cover() {
        // A test drives a method `run` on a struct; `run` must count as reached.
        let src = r#"
            pub struct Job;
            impl Job {
                pub fn run(&self) { self.step(); }
                fn step(&self) {}
                pub fn never(&self) {}
            }
            #[cfg(test)]
            mod tests {
                use super::Job;
                #[test]
                fn drives() { Job.run(); }
            }
        "#;
        let mut s = Scanner::new();
        s.add_source(src, "demo", false);
        let scan = s.finish();
        let missing = unreached_functions(&scan.symbols, &scan.edges);
        let ids: Vec<_> = missing.iter().map(|n| n.id.clone()).collect();
        assert!(ids.iter().any(|i| i.ends_with("::never")), "never() is the gap: {ids:?}");
        assert!(!ids.iter().any(|i| i.ends_with("::run")), "run() is reached by the method call");
        assert!(!ids.iter().any(|i| i.ends_with("::step")), "step() is transitively reached");
    }

    #[test]
    fn unparseable_source_is_skipped_not_fatal() {
        let mut s = Scanner::new();
        s.add_source("fn ok() {}", "demo", false);
        s.add_source("this is not rust @@@ {{{", "demo", false);
        let scan = s.finish();
        assert_eq!(scan.files_scanned, 1);
        assert_eq!(scan.files_skipped, 1);
        assert_eq!(scan.symbols.len(), 1);
    }

    #[test]
    fn integration_test_file_marks_all_fns_as_roots() {
        let src = "fn helper_in_test() {} #[test] fn it() { helper_in_test(); }";
        let mut s = Scanner::new();
        s.add_source(src, "demo::tests::foo", true);
        let scan = s.finish();
        assert!(scan.symbols.iter().all(|r| r.is_test), "every fn in a tests/ file is a root");
    }

    #[test]
    fn module_prefix_reframes_crate_and_flags_tests() {
        let root = Path::new("/repo");
        let (p, t) = module_prefix(root, Path::new("/repo/crates/autonom/src/feeder.rs"));
        assert_eq!(p, "autonom::feeder");
        assert!(!t);
        let (p2, _) = module_prefix(root, Path::new("/repo/crates/autonom/src/lib.rs"));
        assert_eq!(p2, "autonom", "lib.rs tail dropped");
        let (p3, t3) = module_prefix(root, Path::new("/repo/crates/autonom/tests/it.rs"));
        assert!(t3, "a tests/ file is a test file");
        assert_eq!(p3, "autonom::tests::it");
    }
}