use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use candor_report::ReportEntry;
use syn::visit::Visit;
struct Call {
path: String, leaf: String, str_arg: Option<String>, typed: bool,
}
struct FnInfo {
qual: String,
leaf: String,
loc: String,
calls: Vec<Call>,
}
type FieldIndex = HashMap<String, HashMap<String, String>>;
type ReturnIndex = HashMap<String, String>;
struct CallCollector<'a> {
uses: &'a HashMap<String, String>,
vars: HashMap<String, String>,
fields: &'a FieldIndex,
returns: &'a ReturnIndex,
calls: Vec<Call>,
}
fn path_to_string(p: &syn::Path) -> String {
p.segments.iter().map(|s| s.ident.to_string()).collect::<Vec<_>>().join("::")
}
fn type_path(ty: &syn::Type, uses: &HashMap<String, String>) -> Option<String> {
match ty {
syn::Type::Reference(r) => type_path(&r.elem, uses),
syn::Type::Paren(p) => type_path(&p.elem, uses),
syn::Type::Group(g) => type_path(&g.elem, uses),
syn::Type::Path(p) => Some(expand(&path_to_string(&p.path), uses)),
_ => None,
}
}
fn is_ctor(name: &str) -> bool {
matches!(
name,
"new" | "default" | "builder" | "with_capacity" | "connect" | "open" | "init" | "from"
| "from_path" | "from_str" | "with_config" | "create"
)
}
fn ctor_type(expr: &syn::Expr, uses: &HashMap<String, String>, returns: &ReturnIndex) -> Option<String> {
match expr {
syn::Expr::Reference(r) => ctor_type(&r.expr, uses, returns),
syn::Expr::Paren(p) => ctor_type(&p.expr, uses, returns),
syn::Expr::Try(t) => ctor_type(&t.expr, uses, returns),
syn::Expr::Await(a) => ctor_type(&a.base, uses, returns),
syn::Expr::Call(c) => {
let syn::Expr::Path(p) = &*c.func else { return None };
let full = path_to_string(&p.path);
let leaf = full.rsplit("::").next().unwrap_or(&full);
if let Some((ty, last)) = full.rsplit_once("::") {
if is_ctor(last) && !ty.is_empty() {
return Some(expand(ty, uses));
}
}
returns.get(leaf).cloned()
}
_ => None,
}
}
fn unwrap_result_option(ty: &syn::Type) -> &syn::Type {
let syn::Type::Path(p) = ty else { return ty };
let Some(seg) = p.path.segments.last() else { return ty };
if matches!(seg.ident.to_string().as_str(), "Result" | "Option" | "IoResult") {
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
return inner;
}
}
}
ty
}
fn expand(path: &str, uses: &HashMap<String, String>) -> String {
let mut segs: Vec<&str> = path.split("::").collect();
while matches!(segs.first().copied(), Some("crate" | "self" | "super")) {
segs.remove(0);
}
if segs.is_empty() {
return path.to_string();
}
if let Some(full) = uses.get(segs[0]) {
let rest = &segs[1..];
return if rest.is_empty() { full.clone() } else { format!("{full}::{}", rest.join("::")) };
}
segs.join("::")
}
fn first_str_lit(args: &syn::punctuated::Punctuated<syn::Expr, syn::token::Comma>) -> Option<String> {
for a in args {
if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(s), .. }) = a {
let v = s.value();
if !v.trim().is_empty() {
return Some(v);
}
}
}
None
}
impl<'a> CallCollector<'a> {
fn resolve_recv_type(&self, expr: &syn::Expr) -> Option<String> {
match expr {
syn::Expr::Reference(r) => self.resolve_recv_type(&r.expr),
syn::Expr::Paren(p) => self.resolve_recv_type(&p.expr),
syn::Expr::Group(g) => self.resolve_recv_type(&g.expr),
syn::Expr::Try(t) => self.resolve_recv_type(&t.expr),
syn::Expr::Await(a) => self.resolve_recv_type(&a.base),
syn::Expr::MethodCall(m) => {
self.returns
.get(&m.method.to_string())
.cloned()
.or_else(|| self.resolve_recv_type(&m.receiver))
}
syn::Expr::Path(p) => {
let name = p.path.get_ident()?.to_string();
self.vars.get(&name).cloned()
}
syn::Expr::Field(f) => {
let base = self.resolve_recv_type(&f.base)?;
let syn::Member::Named(field) = &f.member else { return None };
let base_leaf = base.rsplit("::").next().unwrap_or(&base);
self.fields.get(base_leaf)?.get(&field.to_string()).cloned()
}
syn::Expr::Call(_) => ctor_type(expr, self.uses, self.returns),
_ => None,
}
}
}
impl<'a, 'ast> Visit<'ast> for CallCollector<'a> {
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if let syn::Expr::Path(p) = &*node.func {
let path = expand(&path_to_string(&p.path), self.uses);
let leaf = path.rsplit("::").next().unwrap_or(&path).to_string();
self.calls.push(Call { path, leaf, str_arg: first_str_lit(&node.args), typed: false });
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
let leaf = node.method.to_string();
let str_arg = first_str_lit(&node.args);
self.calls.push(Call { path: leaf.clone(), leaf: leaf.clone(), str_arg: str_arg.clone(), typed: false });
if let Some(ty) = self.resolve_recv_type(&node.receiver) {
let cr = ty.split("::").next().unwrap_or("");
if !matches!(cr, "std" | "core" | "alloc") {
let path = format!("{ty}::{leaf}");
self.calls.push(Call { path, leaf: leaf.clone(), str_arg, typed: true });
}
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_local(&mut self, node: &'ast syn::Local) {
if let syn::Pat::Type(pt) = &node.pat {
if let syn::Pat::Ident(id) = &*pt.pat {
if let Some(ty) = type_path(&pt.ty, self.uses) {
self.vars.insert(id.ident.to_string(), ty);
}
}
} else if let syn::Pat::Ident(id) = &node.pat {
if let Some(init) = &node.init {
if let Some(ty) = ctor_type(&init.expr, self.uses, self.returns) {
self.vars.insert(id.ident.to_string(), ty);
}
}
}
syn::visit::visit_local(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
if let Ok(exprs) = syn::parse::Parser::parse2(parser, node.tokens.clone()) {
for e in &exprs {
self.visit_expr(e);
}
}
}
}
fn has_cfg(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| a.path().is_ident("cfg"))
}
fn is_test_file_stem(stem: &str) -> bool {
stem == "tests" || stem == "test" || stem.ends_with("_tests") || stem.ends_with("_test")
}
fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| {
a.path().is_ident("cfg") && {
let mut found = false;
let _ = a.parse_nested_meta(|m| {
if m.path.is_ident("test") {
found = true;
}
let _ = m.parse_nested_meta(|inner| {
if inner.path.is_ident("test") {
found = true;
}
Ok(())
});
Ok(())
});
found
}
})
}
#[allow(clippy::too_many_arguments)]
fn scan_items(
items: &[syn::Item],
modpath: &str,
file: &str,
include_tests: bool,
fields: &FieldIndex,
returns: &ReturnIndex,
uses: &mut HashMap<String, String>,
out: &mut Vec<FnInfo>,
) {
for it in items {
if let syn::Item::Use(u) = it {
collect_use(&u.tree, String::new(), uses);
}
}
let qual = |name: &str| if modpath.is_empty() { name.to_string() } else { format!("{modpath}::{name}") };
for it in items {
match it {
syn::Item::Fn(f) => {
let n = f.sig.ident.to_string();
out.push(fninfo(&n, &qual(&n), file, &f.sig, &f.block, None, uses, fields, returns));
}
syn::Item::Impl(im) => {
let tyname = impl_type_name(&im.self_ty);
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
let n = m.sig.ident.to_string();
let q = match &tyname {
Some(t) => qual(&format!("{t}::{n}")),
None => qual(&n),
};
out.push(fninfo(&n, &q, file, &m.sig, &m.block, tyname.as_deref(), uses, fields, returns));
}
}
}
syn::Item::Mod(m) => {
if !include_tests && is_cfg_test(&m.attrs) {
continue; }
if let Some((_, inner)) = &m.content {
let sub = qual(&m.ident.to_string());
let mut subuses = uses.clone();
scan_items(inner, &sub, file, include_tests, fields, returns, &mut subuses, out);
}
}
_ => {}
}
}
}
fn seed_vars(sig: &syn::Signature, self_ty: Option<&str>, uses: &HashMap<String, String>) -> HashMap<String, String> {
let mut vars = HashMap::new();
if let Some(t) = self_ty {
vars.insert("self".to_string(), t.to_string());
}
for arg in &sig.inputs {
if let syn::FnArg::Typed(pt) = arg {
if let syn::Pat::Ident(id) = &*pt.pat {
if let Some(ty) = type_path(&pt.ty, uses) {
vars.insert(id.ident.to_string(), ty);
}
}
}
}
vars
}
#[allow(clippy::too_many_arguments)]
fn fninfo(
leaf: &str,
qual: &str,
file: &str,
sig: &syn::Signature,
block: &syn::Block,
self_ty: Option<&str>,
uses: &HashMap<String, String>,
fields: &FieldIndex,
returns: &ReturnIndex,
) -> FnInfo {
let vars = seed_vars(sig, self_ty, uses);
let mut c = CallCollector { uses, vars, fields, returns, calls: Vec::new() };
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
FnInfo { qual: qual.to_string(), leaf: leaf.to_string(), loc: file.to_string(), calls: c.calls }
}
fn record_return(sig: &syn::Signature, uses: &HashMap<String, String>, rets: &mut HashMap<String, Option<String>>) {
let syn::ReturnType::Type(_, ty) = &sig.output else { return };
let Some(tp) = type_path(unwrap_result_option(ty), uses) else { return };
let leaf = sig.ident.to_string();
match rets.get(&leaf) {
None => {
rets.insert(leaf, Some(tp));
}
Some(Some(prev)) if *prev != tp => {
rets.insert(leaf, None); }
_ => {}
}
}
fn collect_decls(
items: &[syn::Item],
uses: &mut HashMap<String, String>,
fields: &mut FieldIndex,
rets: &mut HashMap<String, Option<String>>,
) {
for it in items {
if let syn::Item::Use(u) = it {
collect_use(&u.tree, String::new(), uses);
}
}
for it in items {
match it {
syn::Item::Struct(s) => {
if let syn::Fields::Named(named) = &s.fields {
let entry = fields.entry(s.ident.to_string()).or_default();
for f in &named.named {
if has_cfg(&f.attrs) {
continue;
}
if let Some(name) = &f.ident {
if let Some(ty) = type_path(&f.ty, uses) {
entry.insert(name.to_string(), ty);
}
}
}
}
}
syn::Item::Fn(f) => record_return(&f.sig, uses, rets),
syn::Item::Impl(im) => {
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
record_return(&m.sig, uses, rets);
}
}
}
syn::Item::Mod(m) => {
if let Some((_, inner)) = &m.content {
let mut subuses = uses.clone();
collect_decls(inner, &mut subuses, fields, rets);
}
}
_ => {}
}
}
}
fn impl_type_name(ty: &syn::Type) -> Option<String> {
if let syn::Type::Path(p) = ty {
return p.path.segments.last().map(|s| s.ident.to_string());
}
None
}
fn collect_use(tree: &syn::UseTree, prefix: String, out: &mut HashMap<String, String>) {
let join = |p: &str, s: &str| if p.is_empty() { s.to_string() } else { format!("{p}::{s}") };
match tree {
syn::UseTree::Path(p) => collect_use(&p.tree, join(&prefix, &p.ident.to_string()), out),
syn::UseTree::Name(n) => {
out.insert(n.ident.to_string(), join(&prefix, &n.ident.to_string()));
}
syn::UseTree::Rename(r) => {
out.insert(r.rename.to_string(), join(&prefix, &r.ident.to_string()));
}
syn::UseTree::Group(g) => {
for t in &g.items {
collect_use(t, prefix.clone(), out);
}
}
syn::UseTree::Glob(_) => {}
}
}
fn module_path(rel: &Path) -> String {
let mut comps: Vec<String> =
rel.components().filter_map(|c| c.as_os_str().to_str().map(String::from)).collect();
if comps.first().map(String::as_str) == Some("src") {
comps.remove(0);
}
if let Some(last) = comps.last_mut() {
let stem = last.trim_end_matches(".rs").to_string();
if stem == "lib" || stem == "main" || stem == "mod" {
comps.pop();
} else {
*last = stem;
}
}
comps.join("::")
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut dir = ".".to_string();
let mut prefix = String::new();
let mut want_json = false;
let mut include_tests = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--out" => prefix = it.next().cloned().unwrap_or_default(),
"--json" => want_json = true,
"--include-tests" => include_tests = true,
"-V" | "--version" => {
println!("candor-scan {}", env!("CARGO_PKG_VERSION"));
return;
}
"-h" | "--help" => {
println!("candor-scan {} — stable-Rust effect scanner (no nightly)", env!("CARGO_PKG_VERSION"));
println!();
println!("USAGE: candor-scan [<dir>] [--out <prefix>] [--json] [--include-tests]");
println!();
println!(" <dir> crate root to scan (default: .)");
println!(" --out <prefix> report path prefix (default: <dir>/.candor/report);");
println!(" writes <prefix>.<crate>.scan.json + a call-graph sidecar");
println!(" --json print the report to stdout instead of writing files");
println!(" --include-tests also scan tests/ benches/ examples/ and #[cfg(test)] modules");
println!(" (off by default → the report describes the crate, not its harness)");
println!(" -V, --version print version");
println!();
println!("Syntactic, so it under-reports vs the full candor nightly lint (no Unknown). It never");
println!("fabricates an effect. See https://github.com/tombaldwin/candor");
return;
}
_ => dir = a.clone(),
}
}
let root = Path::new(&dir);
let crate_name = read_crate_name(root).unwrap_or_else(|| "crate".to_string());
let mut parsed: Vec<(String, syn::File)> = Vec::new();
for entry in walkdir::WalkDir::new(root).into_iter().filter_map(Result::ok) {
let p = entry.path();
if !p.is_file() || p.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let rel = p.strip_prefix(root).unwrap_or(p);
if rel.components().any(|c| {
c.as_os_str()
.to_str()
.is_some_and(|s| s == "target" || (s.starts_with('.') && s != "." && s != ".."))
}) {
continue;
}
if p.file_name().and_then(|s| s.to_str()) == Some("build.rs") {
continue;
}
if !include_tests
&& rel.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("tests") | Some("test") | Some("benches") | Some("examples")
)
})
{
continue;
}
if !include_tests {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
if is_test_file_stem(stem) {
continue;
}
}
}
let Ok(text) = std::fs::read_to_string(p) else { continue };
let Ok(file) = syn::parse_file(&text) else { continue };
parsed.push((rel.to_string_lossy().into_owned(), file));
}
let mut fields: FieldIndex = HashMap::new();
let mut rets_tmp: HashMap<String, Option<String>> = HashMap::new();
for (_, file) in &parsed {
let mut uses = HashMap::new();
collect_decls(&file.items, &mut uses, &mut fields, &mut rets_tmp);
}
let returns: ReturnIndex = rets_tmp.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let mut fns: Vec<FnInfo> = Vec::new();
for (rel, file) in &parsed {
let modpath = module_path(Path::new(rel));
let mut uses = HashMap::new();
scan_items(&file.items, &modpath, rel, include_tests, &fields, &returns, &mut uses, &mut fns);
}
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
for f in &fns {
by_leaf.entry(f.leaf.clone()).or_default().push(f.qual.clone());
if let Some(t2) = tail2(&f.qual) {
by_tail2.entry(t2).or_default().push(f.qual.clone());
}
}
let mut direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
let mut hosts: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut cmds: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut paths: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut loc: HashMap<String, String> = HashMap::new();
for f in &fns {
loc.entry(f.qual.clone()).or_insert_with(|| f.loc.clone());
for c in &f.calls {
let cr = c.path.split("::").next().unwrap_or("");
if let Some(eff) = candor_classify::classify(cr, &c.path) {
direct.entry(f.qual.clone()).or_default().insert(eff);
if let Some(s) = &c.str_arg {
match eff {
"Net" => { hosts.entry(f.qual.clone()).or_default().insert(host_part(s)); }
"Exec" => { cmds.entry(f.qual.clone()).or_default().insert(s.clone()); }
"Fs" => { paths.entry(f.qual.clone()).or_default().insert(s.clone()); }
_ => {}
}
}
}
if !c.typed && !matches!(cr, "std" | "core" | "alloc") {
let targets: Option<&Vec<String>> = tail2(&c.path)
.and_then(|t2| by_tail2.get(&t2))
.or_else(|| by_leaf.get(&c.leaf).filter(|v| v.len() == 1));
if let Some(targets) = targets {
for t in targets {
if t != &f.qual {
calls.entry(f.qual.clone()).or_default().insert(t.clone());
}
}
}
}
}
}
let all: Vec<String> = fns.iter().map(|f| f.qual.clone()).collect();
let inferred = propagate(&direct, &calls, &all);
let hostsacc = propagate_str(&hosts, &calls, &all);
let cmdsacc = propagate_str(&cmds, &calls, &all);
let pathsacc = propagate_str(&paths, &calls, &all);
let mut entries: Vec<ReportEntry> = Vec::new();
let mut cg: BTreeMap<String, Vec<String>> = BTreeMap::new();
for q in &all {
if let Some(cs) = calls.get(q) {
cg.insert(q.clone(), cs.iter().cloned().collect());
}
let inf = inferred.get(q).cloned().unwrap_or_default();
if inf.is_empty() {
continue;
}
entries.push(ReportEntry {
func: q.clone(),
loc: loc.get(q).cloned().unwrap_or_default(),
inferred: inf.iter().map(|s| s.to_string()).collect(),
direct: direct.get(q).map(|d| d.iter().map(|s| s.to_string()).collect()).unwrap_or_default(),
declared: Vec::new(),
undeclared: Vec::new(),
overdeclared: Vec::new(),
unresolved: false,
hash: String::new(),
fs: Vec::new(),
hosts: hostsacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
cmds: cmdsacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
paths: pathsacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
calls: calls.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default(),
});
}
entries.sort_by(|a, b| a.func.cmp(&b.func));
let meta = candor_report::ReportMeta {
version: format!("scan-{}", env!("CARGO_PKG_VERSION")),
toolchain: "stable".into(),
};
let body = candor_report::to_report_json(&meta, &entries).unwrap_or_default();
if want_json {
println!("{body}");
} else {
let prefix = if prefix.is_empty() { format!("{dir}/.candor/report") } else { prefix };
if let Some(parent) = Path::new(&prefix).parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = format!("{prefix}.{crate_name}.scan.json");
let _ = std::fs::write(&file, &body);
let _ = std::fs::write(
format!("{prefix}.{crate_name}.scan.callgraph.json"),
serde_json::to_string(&cg).unwrap_or_default(),
);
eprintln!(
"candor-scan: wrote {} effectful functions to {file} (stable syntactic backend — see --help)",
entries.len()
);
}
}
fn tail2(path: &str) -> Option<String> {
let segs: Vec<&str> = path.split("::").collect();
let n = segs.len();
if n < 2 {
return None;
}
Some(format!("{}::{}", segs[n - 2], segs[n - 1]))
}
fn host_part(h: &str) -> String {
let a = h.split_once("://").map(|(_, r)| r).unwrap_or(h);
let a = a.split('/').next().unwrap_or(a);
a.rsplit_once('@').map(|(_, h)| h).unwrap_or(a).to_string()
}
fn read_crate_name(root: &Path) -> Option<String> {
let txt = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
for line in txt.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("name") {
if let Some(v) = rest.split('=').nth(1) {
return Some(v.trim().trim_matches('"').replace('-', "_"));
}
}
}
None
}
fn propagate(
direct: &HashMap<String, BTreeSet<&'static str>>,
calls: &HashMap<String, BTreeSet<String>>,
all: &[String],
) -> HashMap<String, BTreeSet<&'static str>> {
let mut acc = direct.clone();
for f in all {
acc.entry(f.clone()).or_default();
}
let mut changed = true;
while changed {
changed = false;
for f in all {
let add: BTreeSet<&'static str> = calls
.get(f)
.map(|cs| cs.iter().filter_map(|c| acc.get(c)).flatten().copied().collect())
.unwrap_or_default();
let e = acc.entry(f.clone()).or_default();
let before = e.len();
e.extend(add);
if e.len() != before {
changed = true;
}
}
}
acc
}
fn propagate_str(
direct: &HashMap<String, BTreeSet<String>>,
calls: &HashMap<String, BTreeSet<String>>,
all: &[String],
) -> HashMap<String, BTreeSet<String>> {
let mut acc = direct.clone();
let mut changed = true;
while changed {
changed = false;
for f in all {
let add: BTreeSet<String> = calls
.get(f)
.map(|cs| cs.iter().filter_map(|c| acc.get(c)).flatten().cloned().collect())
.unwrap_or_default();
if add.is_empty() {
continue;
}
let e = acc.entry(f.clone()).or_default();
let before = e.len();
e.extend(add);
if e.len() != before {
changed = true;
}
}
}
acc
}
#[cfg(test)]
mod tests {
use super::*;
fn uses(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn expand_uses_the_use_map_and_strips_local_prefixes() {
let u = uses(&[("fs", "std::fs"), ("Command", "std::process::Command")]);
assert_eq!(expand("fs::read_to_string", &u), "std::fs::read_to_string");
assert_eq!(expand("Command::new", &u), "std::process::Command::new");
assert_eq!(expand("crate::pricing::priced", &u), "pricing::priced");
assert_eq!(expand("self::helper", &u), "helper");
assert_eq!(expand("foo::bar", &u), "foo::bar");
}
#[test]
fn collect_use_expands_groups_and_renames() {
let mut out = HashMap::new();
let tree: syn::UseTree = syn::parse_str("std::process::{Command, Stdio as Pipe}").unwrap();
collect_use(&tree, String::new(), &mut out);
assert_eq!(out.get("Command").map(String::as_str), Some("std::process::Command"));
assert_eq!(out.get("Pipe").map(String::as_str), Some("std::process::Stdio"));
}
#[test]
fn module_path_mirrors_file_based_resolution() {
assert_eq!(module_path(Path::new("src/lib.rs")), "");
assert_eq!(module_path(Path::new("src/main.rs")), "");
assert_eq!(module_path(Path::new("src/pricing.rs")), "pricing");
assert_eq!(module_path(Path::new("src/billing/mod.rs")), "billing");
assert_eq!(module_path(Path::new("src/billing/tax.rs")), "billing::tax");
}
#[test]
fn host_part_strips_scheme_path_and_userinfo() {
assert_eq!(host_part("https://api.stripe.com/v1/charges"), "api.stripe.com");
assert_eq!(host_part("user:pass@db.internal:5432"), "db.internal:5432");
assert_eq!(host_part("example.com"), "example.com");
}
#[test]
fn propagate_is_transitive_across_the_call_graph() {
let mut direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
direct.insert("leaf".into(), ["Fs"].into_iter().collect());
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
calls.insert("mid".into(), ["leaf".to_string()].into_iter().collect());
calls.insert("top".into(), ["mid".to_string()].into_iter().collect());
let all = vec!["leaf".to_string(), "mid".to_string(), "top".to_string(), "pure".to_string()];
let acc = propagate(&direct, &calls, &all);
assert!(acc["leaf"].contains("Fs"));
assert!(acc["mid"].contains("Fs"));
assert!(acc["top"].contains("Fs"));
assert!(acc["pure"].is_empty());
}
#[test]
fn tail2_keys_on_the_qualified_method() {
assert_eq!(tail2("a::b::RequestBuilder::new").as_deref(), Some("RequestBuilder::new"));
assert_eq!(tail2("pricing::compute_price").as_deref(), Some("pricing::compute_price"));
assert_eq!(tail2("send"), None); }
#[test]
fn qualified_tail_disambiguates_same_named_methods() {
let fns = ["http::RequestBuilder::new", "body::Body::new"];
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
for q in fns {
by_leaf.entry("new".into()).or_default().push(q.into());
by_tail2.entry(tail2(q).unwrap()).or_default().push(q.into());
}
let resolved: Option<&Vec<String>> = tail2("api::RequestBuilder::new")
.and_then(|t2| by_tail2.get(&t2))
.or_else(|| by_leaf.get("new").filter(|v| v.len() == 1));
assert_eq!(resolved, Some(&vec!["http::RequestBuilder::new".to_string()]));
let bare: Option<&Vec<String>> =
tail2("new").and_then(|t2| by_tail2.get(&t2)).or_else(|| by_leaf.get("new").filter(|v| v.len() == 1));
assert_eq!(bare, None);
}
#[test]
fn macro_bodies_are_walked_for_hidden_calls() {
let uses = HashMap::new();
let fields = FieldIndex::new();
let block: syn::Block = syn::parse_str(
"{ try_call!(raw::git_remote_fetch(x)); println!(\"{}\", helper()); let _ = matches!(y, Some(_)); }",
)
.unwrap();
let returns = ReturnIndex::new();
let mut c =
CallCollector { uses: &uses, vars: HashMap::new(), fields: &fields, returns: &returns, calls: Vec::new() };
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
let leaves: Vec<&str> = c.calls.iter().map(|c| c.leaf.as_str()).collect();
assert!(leaves.contains(&"git_remote_fetch"), "call inside try_call! macro was missed: {leaves:?}");
assert!(leaves.contains(&"helper"), "call inside println! macro was missed: {leaves:?}");
}
#[test]
fn receiver_type_inference_resolves_method_dispatch() {
let uses = HashMap::new();
let mut fields = FieldIndex::new();
fields.entry("App".into()).or_default().insert("http".into(), "reqwest::Client".into());
let mut vars = HashMap::new();
vars.insert("client".to_string(), "reqwest::Client".to_string());
vars.insert("self".to_string(), "App".to_string());
let returns = ReturnIndex::new();
let block: syn::Block =
syn::parse_str("{ client.get(url).send(); self.http.execute(req); }").unwrap();
let mut c = CallCollector { uses: &uses, vars, fields: &fields, returns: &returns, calls: Vec::new() };
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
let typed: Vec<&str> = c.calls.iter().map(|c| c.path.as_str()).collect();
assert!(typed.contains(&"reqwest::Client::send"), "chain not typed to base: {typed:?}");
assert!(typed.contains(&"reqwest::Client::execute"), "field recv not typed: {typed:?}");
assert_eq!(candor_classify::classify("reqwest", "reqwest::Client::send"), Some("Net"));
assert_eq!(candor_classify::classify("reqwest", "reqwest::Client::execute"), Some("Net"));
}
#[test]
fn return_type_inference_flows_through_local_factories() {
let uses = HashMap::new();
let fields = FieldIndex::new();
let mut returns = ReturnIndex::new();
returns.insert("create_pool".to_string(), "sqlx::PgPool".to_string());
let block: syn::Block =
syn::parse_str("{ let p = create_pool()?; p.fetch_one(q); }").unwrap();
let mut c =
CallCollector { uses: &uses, vars: HashMap::new(), fields: &fields, returns: &returns, calls: Vec::new() };
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
let typed: Vec<&str> = c.calls.iter().map(|c| c.path.as_str()).collect();
assert!(typed.contains(&"sqlx::PgPool::fetch_one"), "return-typed recv not resolved: {typed:?}");
let r: syn::Type = syn::parse_str("std::io::Result<reqwest::Client>").unwrap();
assert_eq!(type_path(unwrap_result_option(&r), &uses).as_deref(), Some("reqwest::Client"));
let o: syn::Type = syn::parse_str("Option<PgPool>").unwrap();
assert_eq!(type_path(unwrap_result_option(&o), &uses).as_deref(), Some("PgPool"));
}
#[test]
fn test_file_stems_are_recognised() {
assert!(is_test_file_stem("tests")); assert!(is_test_file_stem("test"));
assert!(is_test_file_stem("decoder_tests")); assert!(is_test_file_stem("engine_test"));
assert!(!is_test_file_stem("latest")); assert!(!is_test_file_stem("request"));
assert!(!is_test_file_stem("contest"));
assert!(!is_test_file_stem("lib"));
}
#[test]
fn cfg_test_modules_are_recognised() {
let yes1: syn::ItemMod = syn::parse_str("#[cfg(test)] mod tests {}").unwrap();
let yes2: syn::ItemMod =
syn::parse_str("#[cfg(any(test, feature = \"x\"))] mod tests {}").unwrap();
let no1: syn::ItemMod = syn::parse_str("#[cfg(feature = \"std\")] mod imp {}").unwrap();
let no2: syn::ItemMod = syn::parse_str("mod real {}").unwrap();
assert!(is_cfg_test(&yes1.attrs));
assert!(is_cfg_test(&yes2.attrs));
assert!(!is_cfg_test(&no1.attrs));
assert!(!is_cfg_test(&no2.attrs));
}
#[test]
fn classifier_resolves_a_std_fs_call() {
assert_eq!(candor_classify::classify("std", "std::fs::read_to_string"), Some("Fs"));
assert_eq!(candor_classify::classify("std", "std::process::Command::new"), Some("Exec"));
}
}