use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use candor_report::ReportEntry;
use syn::visit::Visit;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct Call {
#[serde(rename = "p")]
path: String, #[serde(rename = "l")]
leaf: String, #[serde(rename = "s", default, skip_serializing_if = "Option::is_none")]
str_arg: Option<String>, #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")]
typed: bool,
#[serde(rename = "m", default, skip_serializing_if = "std::ops::Not::not")]
method: bool,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct FnInfo {
#[serde(rename = "q")]
qual: String,
#[serde(rename = "l")]
leaf: String,
#[serde(rename = "f")]
loc: String,
#[serde(rename = "c", default, skip_serializing_if = "Vec::is_empty")]
calls: Vec<Call>,
#[serde(rename = "u", default, skip_serializing_if = "std::ops::Not::not")]
unresolved: bool,
}
type FieldIndex = HashMap<String, HashMap<String, String>>;
type FieldElemIndex = HashMap<String, HashMap<String, String>>;
type TupleElemIndex = HashMap<String, Vec<Option<String>>>;
type EnumVariantIndex = HashMap<String, String>;
type ReturnIndex = HashMap<String, String>;
type TraitImplIndex = HashMap<String, Vec<String>>;
type TraitFieldIndex = HashMap<String, HashMap<String, Vec<String>>>;
#[derive(Default)]
struct LocalTrait {
count: usize,
methods: std::collections::HashSet<String>,
}
#[derive(Clone, Copy)]
struct TraitIndexes<'a> {
impls: &'a TraitImplIndex,
decls: &'a HashMap<String, LocalTrait>,
fields: &'a TraitFieldIndex,
}
#[derive(Clone, Copy)]
struct ElemIndexes<'a> {
field_elem: &'a FieldElemIndex,
enum_variants: &'a EnumVariantIndex,
}
struct CallCollector<'a> {
uses: &'a HashMap<String, String>,
vars: HashMap<String, String>,
trait_vars: HashMap<String, Vec<String>>,
fields: &'a FieldIndex,
trait_fields: &'a TraitFieldIndex,
trait_impls: &'a TraitImplIndex,
local_traits: &'a HashMap<String, LocalTrait>,
returns: &'a ReturnIndex,
field_elem: &'a FieldElemIndex,
enum_variants: &'a EnumVariantIndex,
elem_of: HashMap<String, String>,
tuple_of: HashMap<String, Vec<Option<String>>>,
calls: Vec<Call>,
closure_vars: std::collections::HashSet<String>,
fn_typed_vars: std::collections::HashSet<String>,
unresolved: bool,
}
struct SendFile(syn::File);
unsafe impl Send for SendFile {}
type ParsedFile = (SendFile, Vec<String>);
fn path_to_string(p: &syn::Path) -> String {
p.segments.iter().map(|s| s.ident.to_string()).collect::<Vec<_>>().join("::")
}
#[derive(Clone, Default)]
struct DepFn {
effects: Vec<&'static str>,
hosts: Vec<String>,
cmds: Vec<String>,
paths: Vec<String>,
tables: Vec<String>,
}
#[derive(Default)]
struct DepIndex {
by_key: HashMap<String, DepFn>,
crates: std::collections::HashSet<String>,
}
fn load_dep_reports(spec: Option<&str>) -> DepIndex {
let mut idx = DepIndex::default();
let Some(spec) = spec else { return idx };
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut seen_files: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let mut push_file = |f: std::path::PathBuf, files: &mut Vec<std::path::PathBuf>| {
let canon = std::fs::canonicalize(&f).unwrap_or(f);
if seen_files.insert(canon.clone()) {
files.push(canon);
}
};
for tok in spec.split(':').filter(|t| !t.is_empty()) {
let p = Path::new(tok);
if p.is_dir() {
for e in walkdir::WalkDir::new(p).into_iter().filter_map(Result::ok) {
let f = e.path();
let name = f.file_name().and_then(|n| n.to_str()).unwrap_or("");
if f.is_file() && name.ends_with(".json") && !name.contains("callgraph") {
push_file(f.to_path_buf(), &mut files);
}
}
} else if p.is_file() {
push_file(p.to_path_buf(), &mut files);
} else {
eprintln!("candor-scan: CANDOR_DEPS entry not found, skipped: {tok}");
}
}
let my_version = format!("scan-{}", env!("CARGO_PKG_VERSION"));
let mut ambiguous: std::collections::HashSet<String> = std::collections::HashSet::new();
for f in &files {
let Ok(text) = std::fs::read_to_string(f) else {
eprintln!("candor-scan: CANDOR_DEPS report unreadable, skipped: {}", f.display());
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
eprintln!("candor-scan: CANDOR_DEPS report unparsable, skipped: {}", f.display());
continue;
};
let version = v.pointer("/candor/version").and_then(|x| x.as_str()).unwrap_or("");
let stale = version != my_version;
let Some(fns) = v.get("functions").and_then(|x| x.as_array()).or_else(|| v.as_array()) else { continue };
let file_crate = f
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_suffix(".scan.json"))
.and_then(|n| n.rsplit('.').next())
.map(str::to_string);
if let Some(c) = &file_crate {
idx.crates.insert(c.clone());
}
for e in fns {
let Some(qual) = e.get("fn").and_then(|x| x.as_str()) else { continue };
let krate = e
.get("hash")
.and_then(|x| x.as_str())
.and_then(|h| h.split_once('#'))
.map(|(c, _)| c.to_string())
.or_else(|| file_crate.clone());
let Some(krate) = krate else { continue };
idx.crates.insert(krate.clone());
let mut de = DepFn::default();
if stale {
de.effects.push("Unknown"); } else {
for s in e.get("inferred").and_then(|x| x.as_array()).into_iter().flatten() {
if let Some(s) = s.as_str() {
de.effects.push(candor_classify::cap_from_name(s).unwrap_or("Unknown"));
}
}
let strs = |k: &str| -> Vec<String> {
e.get(k)
.and_then(|x| x.as_array())
.into_iter()
.flatten()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
};
de.hosts = strs("hosts");
de.cmds = strs("cmds");
de.paths = strs("paths");
de.tables = strs("tables");
}
let mut keys = vec![format!("{krate}#{}", qual.rsplit("::").next().unwrap_or(qual))];
if let Some(t2) = tail2(qual) {
keys.push(format!("{krate}#{t2}"));
}
for k in keys {
if ambiguous.contains(&k) {
continue;
}
#[allow(clippy::map_entry)]
if idx.by_key.contains_key(&k) {
idx.by_key.remove(&k); ambiguous.insert(k);
} else {
idx.by_key.insert(k, de.clone());
}
}
}
}
idx
}
fn toml_section(line: &str) -> Option<&str> {
let l = line.trim();
Some(l.strip_prefix('[')?.strip_suffix(']')?.trim())
}
fn toml_scalar<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.trim().strip_prefix(key)?.trim_start().strip_prefix('=')?.trim();
Some(if let Some(q) = rest.strip_prefix('"') {
q.split('"').next().unwrap_or(q)
} else {
rest.split('#').next().unwrap_or(rest).trim()
})
}
fn cargo_deps(dir: &str) -> (std::collections::HashSet<String>, HashMap<String, String>) {
let mut out = std::collections::HashSet::new();
let mut renames = HashMap::new();
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 || !e.file_type().is_dir() {
return true;
}
let name = e.file_name().to_str().unwrap_or("");
if name == "target" || (name.starts_with('.') && name != "." && name != "..") {
return false;
}
!e.path().join("Cargo.toml").is_file()
})
.filter_map(Result::ok)
{
let p = entry.path();
if p.file_name().and_then(|n| n.to_str()) != Some("Cargo.toml") {
continue;
}
if let Ok(text) = std::fs::read_to_string(p) {
cargo_toml_deps(&text, &mut out, &mut renames);
}
}
(out, renames)
}
fn cargo_toml_deps(
text: &str,
out: &mut std::collections::HashSet<String>,
renames: &mut HashMap<String, String>,
) {
let pkg_re = |l: &str| -> Option<String> {
let bytes = l.as_bytes();
let mut search = 0;
while let Some(rel) = l[search..].find("package") {
let i = search + rel;
let boundary = i == 0 || matches!(bytes[i - 1], b'{' | b',' | b' ' | b'\t');
if boundary {
if let Some(rest) = l[i + "package".len()..].trim_start().strip_prefix('=') {
if let Some(rest) = rest.trim_start().strip_prefix('"') {
return rest.split('"').next().map(|s| s.replace('-', "_"));
}
}
}
search = i + "package".len();
}
None
};
let mut in_deps = false;
let mut header_key: Option<String> = None; for line in text.lines() {
let l = line.trim();
if let Some(inner) = toml_section(line) {
let harness = inner.contains("dev-dependencies") || inner.contains("build-dependencies");
in_deps = !harness && (inner == "dependencies" || inner.ends_with(".dependencies"));
header_key = None;
if !harness && !in_deps {
let name = inner
.rfind(".dependencies.")
.map(|i| &inner[i + ".dependencies.".len()..])
.or_else(|| inner.strip_prefix("dependencies."));
if let Some(name) = name {
if !name.is_empty() && !name.contains('.') {
let key = name.trim_matches('"').replace('-', "_");
out.insert(key.clone());
header_key = Some(key);
}
}
}
continue;
}
if l.is_empty() || l.starts_with('#') {
continue;
}
if let Some(key) = &header_key {
if l.starts_with("package") {
if let Some(real) = pkg_re(l) {
renames.insert(key.clone(), real);
}
}
continue;
}
if !in_deps {
continue;
}
if let Some(name) = l.split('=').next() {
let name = name.trim().trim_matches('"');
if !name.is_empty() {
let key = name.replace('-', "_");
if let Some(brace) = l.find('{') {
if let Some(real) = pkg_re(&l[brace..]) {
if real != key {
renames.insert(key.clone(), real);
}
}
}
out.insert(key);
}
}
}
}
fn bound_leaves(bounds: &syn::punctuated::Punctuated<syn::TypeParamBound, syn::Token![+]>) -> Vec<String> {
bounds
.iter()
.filter_map(|b| match b {
syn::TypeParamBound::Trait(t) => t.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
})
.collect()
}
fn trait_leaves(ty: &syn::Type, generic_bounds: &HashMap<String, Vec<String>>) -> Vec<String> {
match ty {
syn::Type::Reference(r) => trait_leaves(&r.elem, generic_bounds),
syn::Type::Paren(p) => trait_leaves(&p.elem, generic_bounds),
syn::Type::Group(g) => trait_leaves(&g.elem, generic_bounds),
syn::Type::TraitObject(t) => bound_leaves(&t.bounds),
syn::Type::ImplTrait(t) => bound_leaves(&t.bounds),
syn::Type::Path(p) => {
if let Some(id) = p.path.get_ident() {
return generic_bounds.get(&id.to_string()).cloned().unwrap_or_default();
}
let Some(seg) = p.path.segments.last() else { return Vec::new() };
let wrapper = matches!(seg.ident.to_string().as_str(), "Box" | "Rc" | "Arc" | "RefCell" | "Mutex" | "RwLock" | "Cell");
if !wrapper {
return Vec::new();
}
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { return Vec::new() };
args.args
.iter()
.find_map(|a| match a {
syn::GenericArgument::Type(inner) => Some(trait_leaves(inner, generic_bounds)),
_ => None,
})
.unwrap_or_default()
}
_ => Vec::new(),
}
}
fn is_callable_type(ty: &syn::Type, generic_bounds: &HashMap<String, Vec<String>>) -> bool {
match ty {
syn::Type::BareFn(_) => true,
syn::Type::Reference(r) => is_callable_type(&r.elem, generic_bounds),
syn::Type::Paren(p) => is_callable_type(&p.elem, generic_bounds),
syn::Type::Group(g) => is_callable_type(&g.elem, generic_bounds),
_ => trait_leaves(ty, generic_bounds)
.iter()
.any(|l| matches!(l.as_str(), "Fn" | "FnMut" | "FnOnce")),
}
}
fn block_tail_expr(b: &syn::Block) -> Option<&syn::Expr> {
match b.stmts.last() {
Some(syn::Stmt::Expr(e, None)) => Some(e),
_ => None,
}
}
fn seed_fn_typed_vars(sig: &syn::Signature) -> std::collections::HashSet<String> {
let gb = generic_bounds_of(sig);
let mut s = std::collections::HashSet::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pt) = arg {
if let syn::Pat::Ident(id) = &*pt.pat {
if is_callable_type(&pt.ty, &gb) {
s.insert(id.ident.to_string());
}
}
}
}
s
}
fn generic_bounds_of(sig: &syn::Signature) -> HashMap<String, Vec<String>> {
let mut m: HashMap<String, Vec<String>> = HashMap::new();
for gp in &sig.generics.params {
if let syn::GenericParam::Type(tp) = gp {
let leaves = bound_leaves(&tp.bounds);
if !leaves.is_empty() {
m.entry(tp.ident.to_string()).or_default().extend(leaves);
}
}
}
if let Some(w) = &sig.generics.where_clause {
for pred in &w.predicates {
if let syn::WherePredicate::Type(pt) = pred {
if let syn::Type::Path(p) = &pt.bounded_ty {
if let Some(id) = p.path.get_ident() {
let leaves = bound_leaves(&pt.bounds);
if !leaves.is_empty() {
m.entry(id.to_string()).or_default().extend(leaves);
}
}
}
}
}
}
m
}
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 elem_type(ty: &syn::Type, uses: &HashMap<String, String>) -> Option<String> {
match ty {
syn::Type::Reference(r) => elem_type(&r.elem, uses),
syn::Type::Paren(p) => elem_type(&p.elem, uses),
syn::Type::Group(g) => elem_type(&g.elem, uses),
syn::Type::Slice(s) => type_path(&s.elem, uses),
syn::Type::Array(a) => type_path(&a.elem, uses),
syn::Type::Path(p) => {
let seg = p.path.segments.last()?;
let name = seg.ident.to_string();
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { return None };
let first_ty = args.args.iter().find_map(|a| match a {
syn::GenericArgument::Type(t) => Some(t),
_ => None,
})?;
match name.as_str() {
"Vec" | "VecDeque" | "HashSet" | "BTreeSet" | "ContiguousArray" | "BinaryHeap"
| "LinkedList" => type_path(first_ty, uses),
"Box" | "Arc" | "Rc" => elem_type(first_ty, uses),
_ => None,
}
}
_ => None,
}
}
fn tuple_types(ty: &syn::Type, uses: &HashMap<String, String>) -> Option<Vec<Option<String>>> {
match ty {
syn::Type::Reference(r) => tuple_types(&r.elem, uses),
syn::Type::Paren(p) => tuple_types(&p.elem, uses),
syn::Type::Group(g) => tuple_types(&g.elem, uses),
syn::Type::Tuple(t) if t.elems.len() >= 2 => {
Some(t.elems.iter().map(|e| type_path(e, uses)).collect())
}
_ => 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("::") {
let ty_leaf = ty.rsplit("::").next().unwrap_or(ty);
let type_like = ty_leaf.chars().next().is_some_and(|c| c.is_uppercase());
if is_ctor(last) && type_like {
return Some(expand(ty, uses));
}
}
returns.get(leaf).cloned()
}
syn::Expr::Struct(s) => type_from_value_path(&path_to_string(&s.path), uses),
syn::Expr::Path(p) => type_from_value_path(&path_to_string(&p.path), uses),
_ => None,
}
}
fn type_from_value_path(full: &str, uses: &HashMap<String, String>) -> Option<String> {
let camel = |s: &str| {
let mut ch = s.chars();
ch.next().is_some_and(|c| c.is_uppercase())
&& (s.chars().count() == 1 || s.chars().any(|c| c.is_lowercase()))
};
let segs: Vec<&str> = full.split("::").collect();
let last = segs.last()?;
if !camel(last) {
return None;
}
if segs.len() >= 2 && camel(segs[segs.len() - 2]) {
return Some(expand(&segs[..segs.len() - 1].join("::"), uses));
}
Some(expand(full, uses))
}
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();
let rooted_local = matches!(segs.first().copied(), Some("crate" | "self" | "super"));
while matches!(segs.first().copied(), Some("crate" | "self" | "super")) {
segs.remove(0);
}
if segs.is_empty() {
return path.to_string();
}
if !rooted_local {
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
}
fn single_pat_ident(pat: &syn::Pat) -> Option<String> {
match pat {
syn::Pat::Ident(id) => Some(id.ident.to_string()),
syn::Pat::Reference(r) => single_pat_ident(&r.pat),
syn::Pat::Paren(p) => single_pat_ident(&p.pat),
syn::Pat::Type(t) => single_pat_ident(&t.pat),
_ => None,
}
}
fn arm_payload_binding(pat: &syn::Pat, enum_variants: &EnumVariantIndex) -> Option<(String, Option<String>)> {
let ts = match pat {
syn::Pat::TupleStruct(ts) => ts,
syn::Pat::Reference(r) => return arm_payload_binding(&r.pat, enum_variants),
syn::Pat::Paren(p) => return arm_payload_binding(&p.pat, enum_variants),
_ => return None,
};
if ts.elems.len() != 1 {
return None; }
let name = single_pat_ident(ts.elems.first()?)?;
let variant_leaf = ts.path.segments.last()?.ident.to_string();
let ty = enum_variants.get(&variant_leaf).cloned();
Some((name, ty))
}
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) => {
if matches!(
m.method.to_string().as_str(),
"iter" | "into_iter" | "iter_mut" | "drain" | "as_slice" | "as_mut_slice"
| "as_bytes" | "as_str" | "to_vec" | "keys" | "values" | "values_mut"
| "chars" | "bytes" | "get_argv" | "into_inner" | "lines"
) {
return None;
}
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 key = match &f.member {
syn::Member::Named(field) => field.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let base_leaf = base.rsplit("::").next().unwrap_or(&base);
self.fields.get(base_leaf)?.get(&key).cloned()
}
syn::Expr::Call(_) => ctor_type(expr, self.uses, self.returns),
syn::Expr::Index(idx) => self.resolve_elem_type(&idx.expr),
_ => None,
}
}
fn resolve_elem_type(&self, expr: &syn::Expr) -> Option<String> {
match expr {
syn::Expr::Reference(r) => self.resolve_elem_type(&r.expr),
syn::Expr::Paren(p) => self.resolve_elem_type(&p.expr),
syn::Expr::Group(g) => self.resolve_elem_type(&g.expr),
syn::Expr::Try(t) => self.resolve_elem_type(&t.expr),
syn::Expr::Await(a) => self.resolve_elem_type(&a.base),
syn::Expr::Path(p) => {
let name = p.path.get_ident()?.to_string();
self.elem_of.get(&name).cloned()
}
syn::Expr::Field(f) => {
let base = self.resolve_recv_type(&f.base)?;
let key = match &f.member {
syn::Member::Named(field) => field.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let base_leaf = base.rsplit("::").next().unwrap_or(&base);
self.field_elem.get(base_leaf)?.get(&key).cloned()
}
syn::Expr::MethodCall(m) => {
let adapter = matches!(
m.method.to_string().as_str(),
"iter" | "into_iter" | "iter_mut" | "clone" | "drain" | "as_slice" | "as_mut_slice"
| "to_vec" | "values" | "values_mut"
);
if adapter {
self.resolve_elem_type(&m.receiver)
} else {
None
}
}
syn::Expr::Index(idx) => self.resolve_elem_type(&idx.expr),
_ => None,
}
}
fn resolve_recv_traits(&self, expr: &syn::Expr) -> Vec<String> {
if self.trait_vars.is_empty() && self.trait_fields.is_empty() {
return Vec::new();
}
match expr {
syn::Expr::Reference(r) => self.resolve_recv_traits(&r.expr),
syn::Expr::Paren(p) => self.resolve_recv_traits(&p.expr),
syn::Expr::Group(g) => self.resolve_recv_traits(&g.expr),
syn::Expr::Try(t) => self.resolve_recv_traits(&t.expr),
syn::Expr::Await(a) => self.resolve_recv_traits(&a.base),
syn::Expr::Path(p) => p
.path
.get_ident()
.and_then(|id| self.trait_vars.get(&id.to_string()).cloned())
.unwrap_or_default(),
syn::Expr::Field(f) => {
let Some(base) = self.resolve_recv_type(&f.base) else { return Vec::new() };
let key = match &f.member {
syn::Member::Named(field) => field.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let base_leaf = base.rsplit("::").next().unwrap_or(&base);
self.trait_fields.get(base_leaf).and_then(|m| m.get(&key).cloned()).unwrap_or_default()
}
_ => Vec::new(),
}
}
}
impl<'a> CallCollector<'a> {
fn expr_is_fn_typed(&self, expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Path(p) => p.path.get_ident().is_some_and(|i| self.fn_typed_vars.contains(&i.to_string())),
syn::Expr::Paren(p) => self.expr_is_fn_typed(&p.expr),
syn::Expr::Group(g) => self.expr_is_fn_typed(&g.expr),
syn::Expr::Reference(r) => self.expr_is_fn_typed(&r.expr),
syn::Expr::If(e) => block_tail_expr(&e.then_branch).is_some_and(|t| self.expr_is_fn_typed(t)),
_ => false,
}
}
fn scoped_var<R>(&mut self, name: &str, ty: Option<String>, body: impl FnOnce(&mut Self) -> R) -> R {
let prior = self.vars.remove(name);
if let Some(t) = ty {
self.vars.insert(name.to_string(), t);
}
let r = body(self);
match prior {
Some(p) => {
self.vars.insert(name.to_string(), p);
}
None => {
self.vars.remove(name);
}
}
r
}
fn bind_tuple<'p>(
&mut self,
pats: &syn::punctuated::Punctuated<syn::Pat, syn::Token![,]>,
tys: impl Iterator<Item = &'p syn::Type>,
) {
for (pat_el, ty_el) in pats.iter().zip(tys) {
if let Some(name) = single_pat_ident(pat_el) {
self.vars.remove(&name);
self.elem_of.remove(&name);
if let Some(ty) = type_path(ty_el, self.uses) {
self.vars.insert(name.clone(), ty);
}
if let Some(e) = elem_type(ty_el, self.uses) {
self.elem_of.insert(name, e);
}
}
}
}
}
impl<'a, 'ast> Visit<'ast> for CallCollector<'a> {
fn visit_stmt(&mut self, node: &'ast syn::Stmt) {
if stmt_cfg_inactive(node) {
return;
}
syn::visit::visit_stmt(self, node);
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
let mut func = &*node.func;
loop {
match func {
syn::Expr::Paren(p) => func = &p.expr,
syn::Expr::Group(g) => func = &g.expr,
_ => break,
}
}
match func {
syn::Expr::Path(p) => {
let ident = p.path.get_ident().map(|id| id.to_string());
if ident.as_ref().is_some_and(|n| self.fn_typed_vars.contains(n)) {
self.unresolved = true;
} else {
let is_closure_call = !self.closure_vars.is_empty()
&& ident.as_ref().is_some_and(|n| self.closure_vars.contains(n));
if !is_closure_call {
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, method: false });
}
}
}
_ => self.unresolved = true,
}
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, method: true });
if let Some(ty) = self.resolve_recv_type(&node.receiver) {
let cr = ty.split("::").next().unwrap_or("");
let std_path_recv = ty == "std::path::Path" || ty == "std::path::PathBuf";
if !matches!(cr, "std" | "core" | "alloc") || std_path_recv {
let path = format!("{ty}::{leaf}");
self.calls.push(Call { path, leaf: leaf.clone(), str_arg, typed: true, method: true });
}
} else {
for tr in self.resolve_recv_traits(&node.receiver) {
let Some(lt) = self.local_traits.get(&tr) else { continue }; if !lt.methods.contains(&leaf) {
continue; }
if lt.count > 1 {
self.unresolved = true; continue;
}
match self.trait_impls.get(&tr) {
Some(impls) if impls.len() <= 12 => {
for ty in impls {
self.calls.push(Call {
path: format!("{ty}::{leaf}"),
leaf: leaf.clone(),
str_arg: str_arg.clone(),
typed: true,
method: true,
});
}
}
_ => self.unresolved = true, }
}
}
let elem_adapter = matches!(
leaf.as_str(),
"for_each" | "map" | "filter" | "filter_map" | "flat_map" | "find" | "find_map" | "any"
| "all" | "position" | "inspect" | "take_while" | "skip_while" | "map_while"
| "partition" | "fold" | "try_for_each" | "retain" | "sort_by" | "sort_by_key"
| "min_by_key" | "max_by_key" | "count"
);
let elem_ty = if elem_adapter { self.resolve_elem_type(&node.receiver) } else { None };
let closure_param = if elem_adapter {
node.args.iter().find_map(|a| match a {
syn::Expr::Closure(cl) if cl.inputs.len() == 1 => single_pat_ident(cl.inputs.first()?),
_ => None,
})
} else {
None
};
self.visit_expr(&node.receiver);
if let Some(name) = closure_param {
for a in &node.args {
if let syn::Expr::Closure(cl) = a {
if cl.inputs.len() == 1 && single_pat_ident(cl.inputs.first().unwrap()).as_deref() == Some(name.as_str()) {
self.scoped_var(&name, elem_ty.clone(), |s| s.visit_expr(&cl.body));
continue;
}
}
self.visit_expr(a);
}
} else {
for a in &node.args {
self.visit_expr(a);
}
}
}
fn visit_expr_for_loop(&mut self, node: &'ast syn::ExprForLoop) {
self.visit_expr(&node.expr);
if let Some(name) = single_pat_ident(&node.pat) {
let elem = self.resolve_elem_type(&node.expr);
self.scoped_var(&name, elem, |s| s.visit_block(&node.body));
} else {
self.visit_block(&node.body);
}
}
fn visit_arm(&mut self, node: &'ast syn::Arm) {
let binding = arm_payload_binding(&node.pat, self.enum_variants);
if let Some((name, ty)) = binding {
self.scoped_var(&name, ty, |s| {
if let Some((_, guard)) = &node.guard {
s.visit_expr(guard);
}
s.visit_expr(&node.body);
});
} else {
syn::visit::visit_arm(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 is_callable_type(&pt.ty, &HashMap::new()) {
self.fn_typed_vars.insert(id.ident.to_string());
self.vars.remove(&id.ident.to_string());
} else {
self.fn_typed_vars.remove(&id.ident.to_string()); }
let leaves = trait_leaves(&pt.ty, &HashMap::new());
if !leaves.is_empty() {
self.vars.remove(&id.ident.to_string()); self.trait_vars.insert(id.ident.to_string(), leaves);
} else if let Some(ty) = type_path(&pt.ty, self.uses) {
self.vars.insert(id.ident.to_string(), ty);
}
if let Some(e) = elem_type(&pt.ty, self.uses) {
self.elem_of.insert(id.ident.to_string(), e);
}
self.tuple_of.remove(&id.ident.to_string());
if let Some(t) = tuple_types(&pt.ty, self.uses) {
self.tuple_of.insert(id.ident.to_string(), t);
}
} else if let syn::Pat::Tuple(tup) = &*pt.pat {
if let syn::Type::Tuple(tty) = &*pt.ty {
self.bind_tuple(&tup.elems, tty.elems.iter());
}
}
} else if let syn::Pat::Tuple(tup) = &node.pat {
let init = node.init.as_ref().map(|i| &*i.expr);
let src_tuple = match init {
Some(syn::Expr::Path(p)) => p
.path
.get_ident()
.and_then(|id| self.tuple_of.get(&id.to_string()))
.cloned(),
_ => None,
};
for (i, pat_el) in tup.elems.iter().enumerate() {
let Some(name) = single_pat_ident(pat_el) else { continue };
self.vars.remove(&name);
self.elem_of.remove(&name);
self.tuple_of.remove(&name);
let ty = src_tuple
.as_ref()
.and_then(|t| t.get(i).cloned().flatten())
.or_else(|| match init {
Some(syn::Expr::Tuple(it)) => {
it.elems.iter().nth(i).and_then(|e| ctor_type(e, self.uses, self.returns))
}
_ => None,
});
if let Some(ty) = ty {
self.vars.insert(name, ty);
}
}
} else if let syn::Pat::Ident(id) = &node.pat {
if let Some(init) = &node.init {
if matches!(&*init.expr, syn::Expr::Closure(_)) {
self.closure_vars.insert(id.ident.to_string());
} else {
self.closure_vars.remove(&id.ident.to_string());
if self.expr_is_fn_typed(&init.expr) {
self.fn_typed_vars.insert(id.ident.to_string());
self.vars.remove(&id.ident.to_string());
} else {
self.fn_typed_vars.remove(&id.ident.to_string());
if let Some(ty) = ctor_type(&init.expr, self.uses, self.returns) {
self.vars.insert(id.ident.to_string(), ty);
}
}
self.elem_of.remove(&id.ident.to_string());
if let Some(e) = self.resolve_elem_type(&init.expr) {
self.elem_of.insert(id.ident.to_string(), e);
}
}
}
}
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_build_script(rel: &std::path::Path) -> bool {
rel == std::path::Path::new("build.rs")
}
fn cfg_meta_requires_test(m: &syn::meta::ParseNestedMeta) -> bool {
if m.path.is_ident("test") {
return true;
}
if m.path.is_ident("any") || m.path.is_ident("all") {
let mut inner_test = false;
let _ = m.parse_nested_meta(|inner| {
if cfg_meta_requires_test(&inner) {
inner_test = true;
}
Ok(())
});
return inner_test;
}
false }
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 cfg_meta_requires_test(&m) {
found = true;
}
Ok(())
});
found
}
})
}
type FeatureSets = (std::collections::HashSet<String>, std::collections::HashSet<String>);
static CFG_FEATURES: std::sync::OnceLock<std::sync::RwLock<FeatureSets>> = std::sync::OnceLock::new();
fn cfg_cell() -> &'static std::sync::RwLock<FeatureSets> {
CFG_FEATURES.get_or_init(|| std::sync::RwLock::new((Default::default(), Default::default())))
}
fn set_cfg_features(f: FeatureSets) {
*cfg_cell().write().unwrap() = f;
}
fn active_features_sorted() -> Vec<String> {
let mut v: Vec<String> = cfg_cell().read().unwrap().0.iter().cloned().collect();
v.sort();
v
}
fn push_quoted(s: &str, out: &mut Vec<String>) {
let mut rest = s;
while let Some(i) = rest.find('"') {
rest = &rest[i + 1..];
if let Some(j) = rest.find('"') {
out.push(rest[..j].to_string());
rest = &rest[j + 1..];
} else {
break;
}
}
}
fn parse_features(root: &std::path::Path) -> (std::collections::HashSet<String>, std::collections::HashSet<String>) {
use std::collections::{HashMap, HashSet};
let txt = match std::fs::read_to_string(root.join("Cargo.toml")) {
Ok(t) => t,
Err(_) => return (HashSet::new(), HashSet::new()),
};
let mut feats: HashMap<String, Vec<String>> = HashMap::new();
let mut in_features = false;
let mut cur: Option<(String, Vec<String>)> = None; for line in txt.lines() {
if let Some((k, vals)) = cur.as_mut() {
push_quoted(line, vals);
if line.contains(']') {
feats.insert(std::mem::take(k), std::mem::take(vals));
cur = None;
}
continue;
}
let t = line.trim();
if let Some(sec) = toml_section(line) {
in_features = sec == "features";
continue;
}
if !in_features || t.is_empty() || t.starts_with('#') {
continue;
}
if let Some(eq) = t.find('=') {
let key = t[..eq].trim().trim_matches('"').to_string();
let rhs = t[eq + 1..].trim();
if let Some(arr) = rhs.strip_prefix('[') {
let mut vals = Vec::new();
push_quoted(arr, &mut vals);
if rhs.contains(']') {
feats.insert(key, vals); } else {
cur = Some((key, vals)); }
}
}
}
let declared: HashSet<String> = feats.keys().cloned().collect();
let mut active: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = feats.get("default").cloned().unwrap_or_default();
while let Some(f) = stack.pop() {
if f.contains(':') || f.contains('/') {
continue;
}
if active.insert(f.clone()) {
if let Some(next) = feats.get(&f) {
stack.extend(next.iter().cloned());
}
}
}
(active, declared)
}
fn cfg_eval(m: &syn::meta::ParseNestedMeta, active: &std::collections::HashSet<String>,
declared: &std::collections::HashSet<String>) -> Option<bool> {
if m.path.is_ident("feature") {
let v = m.value().ok().and_then(|v| v.parse::<syn::LitStr>().ok());
return v.and_then(|lit| {
let name = lit.value();
if active.contains(&name) {
Some(true)
} else if declared.contains(&name) {
Some(false)
} else {
None
}
});
}
if m.path.is_ident("not") {
let mut inner: Option<bool> = None;
let _ = m.parse_nested_meta(|n| { inner = cfg_eval(&n, active, declared); Ok(()) });
return inner.map(|b| !b);
}
if m.path.is_ident("all") {
let (mut any_false, mut all_true, mut saw) = (false, true, false);
let _ = m.parse_nested_meta(|n| { saw = true; match cfg_eval(&n, active, declared) { Some(false) => any_false = true, Some(true) => {}, None => all_true = false }; Ok(()) });
if any_false { return Some(false); }
if saw && all_true { return Some(true); }
return None;
}
if m.path.is_ident("any") {
let (mut any_true, mut all_false, mut saw) = (false, true, false);
let _ = m.parse_nested_meta(|n| { saw = true; match cfg_eval(&n, active, declared) { Some(true) => any_true = true, Some(false) => {}, None => all_false = false }; Ok(()) });
if any_true { return Some(true); }
if saw && all_false { return Some(false); }
return None;
}
None }
fn is_cfg_inactive(attrs: &[syn::Attribute]) -> bool {
if !attrs.iter().any(|a| a.path().is_ident("cfg")) {
return false; }
let guard = cfg_cell().read().unwrap();
let (active, declared) = &*guard;
if declared.is_empty() {
return false; }
attrs.iter().any(|a| {
a.path().is_ident("cfg") && {
let mut verdict: Option<bool> = None;
let _ = a.parse_nested_meta(|m| { verdict = cfg_eval(&m, active, declared); Ok(()) });
verdict == Some(false)
}
})
}
fn expr_attrs(e: &syn::Expr) -> &[syn::Attribute] {
match e {
syn::Expr::Block(x) => &x.attrs,
syn::Expr::If(x) => &x.attrs,
syn::Expr::Match(x) => &x.attrs,
syn::Expr::Unsafe(x) => &x.attrs,
syn::Expr::ForLoop(x) => &x.attrs,
syn::Expr::While(x) => &x.attrs,
syn::Expr::Loop(x) => &x.attrs,
syn::Expr::Call(x) => &x.attrs,
syn::Expr::MethodCall(x) => &x.attrs,
syn::Expr::Macro(x) => &x.attrs,
syn::Expr::Async(x) => &x.attrs,
syn::Expr::Const(x) => &x.attrs,
_ => &[],
}
}
fn stmt_cfg_inactive(stmt: &syn::Stmt) -> bool {
match stmt {
syn::Stmt::Local(l) => is_cfg_inactive(&l.attrs),
syn::Stmt::Macro(m) => is_cfg_inactive(&m.attrs),
syn::Stmt::Expr(e, _) => is_cfg_inactive(expr_attrs(e)),
syn::Stmt::Item(_) => false, }
}
#[allow(clippy::too_many_arguments)]
fn scan_items(
items: &[syn::Item],
modpath: &str,
locs: &[String],
loc_idx: &mut usize,
include_tests: bool,
fields: &FieldIndex,
returns: &ReturnIndex,
traits: TraitIndexes,
elems: ElemIndexes,
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) => {
if !include_tests && is_cfg_test(&f.attrs) {
continue;
}
let n = f.sig.ident.to_string();
let loc = next_loc(locs, loc_idx);
out.push(fninfo(&n, &qual(&n), &loc, &f.sig, &f.block, None, uses, fields, returns, traits, elems));
}
syn::Item::Impl(im) => {
if !include_tests && is_cfg_test(&im.attrs) {
continue; }
let tyname = impl_type_name(&im.self_ty);
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
if !include_tests && is_cfg_test(&m.attrs) {
continue; }
let n = m.sig.ident.to_string();
let q = match &tyname {
Some(t) => qual(&format!("{t}::{n}")),
None => qual(&n),
};
let loc = next_loc(locs, loc_idx);
out.push(fninfo(&n, &q, &loc, &m.sig, &m.block, tyname.as_deref(), uses, fields, returns, traits, elems));
}
}
}
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, locs, loc_idx, include_tests, fields, returns, traits, elems, &mut subuses, out);
}
}
syn::Item::Trait(tr) => {
if !include_tests && is_cfg_test(&tr.attrs) {
continue;
}
let tname = tr.ident.to_string();
for ti in &tr.items {
if let syn::TraitItem::Fn(m) = ti {
let Some(block) = &m.default else { continue }; if !include_tests && is_cfg_test(&m.attrs) {
continue;
}
let n = m.sig.ident.to_string();
let loc = next_loc(locs, loc_idx);
out.push(fninfo(&n, &qual(&format!("{tname}::{n}")), &loc, &m.sig, block,
Some(&tname), uses, fields, returns, traits, elems));
}
}
}
_ => {}
}
}
}
fn next_loc(locs: &[String], loc_idx: &mut usize) -> String {
debug_assert!(*loc_idx < locs.len(), "fn_locs/scan_items walk-order drift: more fns than locs");
let l = locs.get(*loc_idx).cloned().unwrap_or_default();
*loc_idx += 1;
l
}
fn fn_locs(items: &[syn::Item], file: &str, include_tests: bool, out: &mut Vec<String>) {
use syn::spanned::Spanned;
let loc = |sp: proc_macro2::Span| {
let s = sp.start();
format!("{file}:{}:{}", s.line, s.column + 1)
};
for it in items {
match it {
syn::Item::Fn(f) => {
if !include_tests && is_cfg_test(&f.attrs) {
continue;
}
out.push(loc(f.span()));
}
syn::Item::Impl(im) => {
if !include_tests && is_cfg_test(&im.attrs) {
continue;
}
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
if !include_tests && is_cfg_test(&m.attrs) {
continue;
}
out.push(loc(m.span()));
}
}
}
syn::Item::Mod(m) => {
if !include_tests && is_cfg_test(&m.attrs) {
continue;
}
if let Some((_, inner)) = &m.content {
fn_locs(inner, file, include_tests, out);
}
}
syn::Item::Trait(tr) => {
if !include_tests && is_cfg_test(&tr.attrs) {
continue;
}
for ti in &tr.items {
if let syn::TraitItem::Fn(m) = ti {
if m.default.is_none() {
continue;
}
if !include_tests && is_cfg_test(&m.attrs) {
continue;
}
out.push(loc(m.span()));
}
}
}
_ => {}
}
}
}
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
}
fn seed_elem_of(
sig: &syn::Signature,
vars: &mut HashMap<String, String>,
uses: &HashMap<String, String>,
) -> (HashMap<String, String>, TupleElemIndex) {
let mut elem_of = HashMap::new();
let mut tuple_of: TupleElemIndex = HashMap::new();
for arg in &sig.inputs {
let syn::FnArg::Typed(pt) = arg else { continue };
match &*pt.pat {
syn::Pat::Ident(id) => {
if let Some(e) = elem_type(&pt.ty, uses) {
elem_of.insert(id.ident.to_string(), e);
}
if let Some(t) = tuple_types(&pt.ty, uses) {
tuple_of.insert(id.ident.to_string(), t);
}
}
syn::Pat::Tuple(tup) => {
if let syn::Type::Tuple(tty) = &*pt.ty {
for (pat_el, ty_el) in tup.elems.iter().zip(tty.elems.iter()) {
if let Some(name) = single_pat_ident(pat_el) {
if let Some(ty) = type_path(ty_el, uses) {
vars.insert(name.clone(), ty);
}
if let Some(e) = elem_type(ty_el, uses) {
elem_of.insert(name, e);
}
}
}
}
}
_ => {}
}
}
(elem_of, tuple_of)
}
fn seed_trait_vars(sig: &syn::Signature) -> HashMap<String, Vec<String>> {
let gb = generic_bounds_of(sig);
let mut m = HashMap::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pt) = arg {
if let syn::Pat::Ident(id) = &*pt.pat {
let leaves = trait_leaves(&pt.ty, &gb);
if !leaves.is_empty() {
m.insert(id.ident.to_string(), leaves);
}
}
}
}
m
}
#[allow(clippy::too_many_arguments)]
fn fninfo(
leaf: &str,
qual: &str,
loc: &str,
sig: &syn::Signature,
block: &syn::Block,
self_ty: Option<&str>,
uses: &HashMap<String, String>,
fields: &FieldIndex,
returns: &ReturnIndex,
traits: TraitIndexes,
elems: ElemIndexes,
) -> FnInfo {
let mut local_uses = HashMap::new();
for stmt in &block.stmts {
if let syn::Stmt::Item(syn::Item::Use(u)) = stmt {
collect_use(&u.tree, String::new(), &mut local_uses);
}
}
let merged: HashMap<String, String>;
let uses: &HashMap<String, String> = if local_uses.is_empty() {
uses
} else {
let mut m = uses.clone();
m.extend(local_uses);
merged = m;
&merged
};
let trait_vars = seed_trait_vars(sig);
let fn_typed_vars = seed_fn_typed_vars(sig);
let mut vars = seed_vars(sig, self_ty, uses);
for k in trait_vars.keys() {
vars.remove(k);
}
for k in &fn_typed_vars {
vars.remove(k);
}
let (elem_of, tuple_of) = seed_elem_of(sig, &mut vars, uses);
let mut c = CallCollector {
uses,
vars,
trait_vars,
fields,
trait_fields: traits.fields,
trait_impls: traits.impls,
local_traits: traits.decls,
returns,
field_elem: elems.field_elem,
enum_variants: elems.enum_variants,
elem_of,
tuple_of,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
fn_typed_vars,
unresolved: false,
};
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
FnInfo {
qual: qual.to_string(),
leaf: leaf.to_string(),
loc: loc.to_string(),
calls: c.calls,
unresolved: c.unresolved,
}
}
fn record_return(
sig: &syn::Signature,
uses: &HashMap<String, String>,
rets: &mut HashMap<String, Option<String>>,
self_ty: Option<&str>,
) {
let syn::ReturnType::Type(_, ty) = &sig.output else { return };
let Some(mut tp) = type_path(unwrap_result_option(ty), uses) else { return };
if tp == "Self" {
match self_ty {
Some(s) => tp = s.to_string(),
None => 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); }
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
fn collect_decls(
items: &[syn::Item],
include_tests: bool,
uses: &mut HashMap<String, String>,
fields: &mut FieldIndex,
field_elem: &mut FieldElemIndex,
rets: &mut HashMap<String, Option<String>>,
enum_tmp: &mut HashMap<String, Option<String>>,
trait_impls: &mut TraitImplIndex,
local_traits: &mut HashMap<String, LocalTrait>,
trait_fields: &mut TraitFieldIndex,
prim_aliases: &mut std::collections::HashSet<String>,
) {
for it in items {
if let syn::Item::Use(u) = it {
collect_use(&u.tree, String::new(), uses);
}
}
let no_generics = HashMap::new();
for it in items {
match it {
syn::Item::Struct(s) => {
match &s.fields {
syn::Fields::Named(named) => {
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 {
let leaves = trait_leaves(&f.ty, &no_generics);
if !leaves.is_empty() {
trait_fields
.entry(s.ident.to_string())
.or_default()
.insert(name.to_string(), leaves);
} else if let Some(ty) = type_path(&f.ty, uses) {
entry.insert(name.to_string(), ty);
}
if let Some(e) = elem_type(&f.ty, uses) {
field_elem
.entry(s.ident.to_string())
.or_default()
.insert(name.to_string(), e);
}
}
}
}
syn::Fields::Unnamed(unnamed) => {
let entry = fields.entry(s.ident.to_string()).or_default();
for (i, f) in unnamed.unnamed.iter().enumerate() {
if has_cfg(&f.attrs) {
continue;
}
if let Some(ty) = type_path(&f.ty, uses) {
entry.insert(i.to_string(), ty);
}
if let Some(e) = elem_type(&f.ty, uses) {
field_elem
.entry(s.ident.to_string())
.or_default()
.insert(i.to_string(), e);
}
}
}
syn::Fields::Unit => {}
}
}
syn::Item::Fn(f) => record_return(&f.sig, uses, rets, None),
syn::Item::Enum(en) => {
for v in &en.variants {
if has_cfg(&v.attrs) {
continue;
}
let syn::Fields::Unnamed(unnamed) = &v.fields else { continue };
if unnamed.unnamed.len() != 1 {
continue;
}
let Some(tp) = type_path(&unnamed.unnamed[0].ty, uses) else { continue };
let leaf = v.ident.to_string();
match enum_tmp.get(&leaf) {
None => {
enum_tmp.insert(leaf, Some(tp));
}
Some(Some(prev)) if *prev != tp => {
enum_tmp.insert(leaf, None); }
_ => {}
}
}
}
syn::Item::Type(it) => {
if is_non_nominal_type(&it.ty) {
prim_aliases.insert(it.ident.to_string());
}
}
syn::Item::Trait(t) => {
let e = local_traits.entry(t.ident.to_string()).or_default();
e.count += 1;
for ti in &t.items {
if let syn::TraitItem::Fn(m) = ti {
e.methods.insert(m.sig.ident.to_string());
}
}
}
syn::Item::Impl(im) => {
let self_ty = impl_type_name(&im.self_ty);
if let (Some((_, tr, _)), Some(ty)) = (&im.trait_, &self_ty) {
if let Some(leaf) = tr.segments.last() {
trait_impls.entry(leaf.ident.to_string()).or_default().push(ty.clone());
}
}
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
record_return(&m.sig, uses, rets, self_ty.as_deref());
}
}
}
syn::Item::Mod(m) => {
if !include_tests && is_cfg_test(&m.attrs) {
continue;
}
if let Some((_, inner)) = &m.content {
let mut subuses = uses.clone();
collect_decls(inner, include_tests, &mut subuses, fields, field_elem, rets, enum_tmp, trait_impls, local_traits, trait_fields, prim_aliases);
}
}
_ => {}
}
}
}
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 is_non_nominal_type(ty: &syn::Type) -> bool {
match ty {
syn::Type::Array(_) | syn::Type::Slice(_) | syn::Type::Tuple(_)
| syn::Type::Ptr(_) | syn::Type::Reference(_) | syn::Type::BareFn(_) => true,
syn::Type::Path(p) if p.qself.is_none() && p.path.segments.len() == 1 => {
const PRIMS: &[&str] = &[
"u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64", "i128",
"isize", "f32", "f64", "bool", "char", "str",
];
let seg = &p.path.segments[0];
matches!(seg.arguments, syn::PathArguments::None)
&& PRIMS.contains(&seg.ident.to_string().as_str())
}
_ => false,
}
}
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) => {
let id = n.ident.to_string();
if id == "self" {
if let Some(last) = prefix.rsplit("::").next() {
out.insert(last.to_string(), prefix.clone());
}
} else {
out.insert(id.clone(), join(&prefix, &id));
}
}
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 let Some(i) = comps.iter().rposition(|c| c == "src") {
comps.drain(..=i);
}
if let Some(last) = comps.last() {
let stem = last.trim_end_matches(".rs").to_string();
if stem == "lib" || stem == "main" || stem == "mod" {
comps.pop();
} else {
let parts: Vec<String> = stem.split('.').map(String::from).collect();
comps.pop();
comps.extend(parts);
}
}
comps.join("::")
}
thread_local! {
static INCREMENTAL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn cache_schema(include_tests: bool) -> String {
format!("scan-{}/rev4/tests={}", env!("CARGO_PKG_VERSION"), include_tests)
}
fn fnv1a(bytes: &[u8]) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
struct FileDecls {
fields: FieldIndex,
field_elem: FieldElemIndex,
rets: HashMap<String, Option<String>>,
enum_tmp: HashMap<String, Option<String>>,
trait_impls: TraitImplIndex,
trait_decls: HashMap<String, (usize, Vec<String>)>,
trait_fields: TraitFieldIndex,
prim_aliases: Vec<String>,
}
fn file_decls(items: &[syn::Item], include_tests: bool) -> FileDecls {
let mut uses = HashMap::new();
let mut fields = HashMap::new();
let mut field_elem = HashMap::new();
let mut rets = HashMap::new();
let mut enum_tmp = HashMap::new();
let mut trait_impls = HashMap::new();
let mut trait_decls: HashMap<String, LocalTrait> = HashMap::new();
let mut trait_fields = HashMap::new();
let mut prim_aliases = std::collections::HashSet::new();
collect_decls(items, include_tests, &mut uses, &mut fields, &mut field_elem, &mut rets,
&mut enum_tmp, &mut trait_impls, &mut trait_decls, &mut trait_fields, &mut prim_aliases);
FileDecls {
fields,
field_elem,
rets,
enum_tmp,
trait_impls,
trait_decls: trait_decls
.into_iter()
.map(|(k, v)| (k, (v.count, v.methods.into_iter().collect())))
.collect(),
trait_fields,
prim_aliases: prim_aliases.into_iter().collect(),
}
}
#[derive(Default)]
struct MergedDecls {
fields: FieldIndex,
field_elem: FieldElemIndex,
rets: HashMap<String, Option<String>>,
enum_tmp: HashMap<String, Option<String>>,
trait_impls: TraitImplIndex,
trait_decls: HashMap<String, LocalTrait>,
trait_fields: TraitFieldIndex,
prim_aliases: std::collections::HashSet<String>,
}
fn merge_decls(acc: &mut MergedDecls, fd: &FileDecls) {
for (s, fmap) in &fd.fields {
let e = acc.fields.entry(s.clone()).or_default();
for (k, v) in fmap {
e.insert(k.clone(), v.clone());
}
}
for (s, fmap) in &fd.field_elem {
let e = acc.field_elem.entry(s.clone()).or_default();
for (k, v) in fmap {
e.insert(k.clone(), v.clone());
}
}
let merge_amb = |dst: &mut HashMap<String, Option<String>>, src: &HashMap<String, Option<String>>| {
for (leaf, val) in src {
match val {
None => {
dst.insert(leaf.clone(), None); }
Some(tp) => match dst.get(leaf) {
None => {
dst.insert(leaf.clone(), Some(tp.clone()));
}
Some(Some(prev)) if prev != tp => {
dst.insert(leaf.clone(), None); }
Some(Some(_)) => {} Some(None) => {} },
}
}
};
merge_amb(&mut acc.rets, &fd.rets);
merge_amb(&mut acc.enum_tmp, &fd.enum_tmp);
for (tr, tys) in &fd.trait_impls {
acc.trait_impls.entry(tr.clone()).or_default().extend(tys.iter().cloned());
}
for (tr, (count, methods)) in &fd.trait_decls {
let e = acc.trait_decls.entry(tr.clone()).or_default();
e.count += count;
for m in methods {
e.methods.insert(m.clone());
}
}
for (s, fmap) in &fd.trait_fields {
let e = acc.trait_fields.entry(s.clone()).or_default();
for (k, v) in fmap {
e.insert(k.clone(), v.clone());
}
}
for a in &fd.prim_aliases {
acc.prim_aliases.insert(a.clone()); }
}
fn decl_index_digest(m: &MergedDecls) -> String {
let mut s = String::new();
let nested = |s: &mut String, tag: &str, map: &HashMap<String, HashMap<String, String>>| {
s.push_str(tag);
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for k in keys {
s.push('|');
s.push_str(k);
let inner = &map[k];
let mut ik: Vec<&String> = inner.keys().collect();
ik.sort();
for f in ik {
s.push(';');
s.push_str(f);
s.push('=');
s.push_str(&inner[f]);
}
}
s.push('\n');
};
nested(&mut s, "fields", &m.fields);
nested(&mut s, "field_elem", &m.field_elem);
let amb = |s: &mut String, tag: &str, map: &HashMap<String, Option<String>>| {
s.push_str(tag);
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for k in keys {
s.push('|');
s.push_str(k);
s.push('=');
s.push_str(map[k].as_deref().unwrap_or("\u{0}AMBIG"));
}
s.push('\n');
};
amb(&mut s, "rets", &m.rets);
amb(&mut s, "enum", &m.enum_tmp);
s.push_str("trait_impls");
let mut tik: Vec<&String> = m.trait_impls.keys().collect();
tik.sort();
for k in tik {
s.push('|');
s.push_str(k);
for ty in &m.trait_impls[k] {
s.push(';');
s.push_str(ty);
}
}
s.push('\n');
s.push_str("trait_decls");
let mut tdk: Vec<&String> = m.trait_decls.keys().collect();
tdk.sort();
for k in tdk {
let lt = &m.trait_decls[k];
s.push('|');
s.push_str(k);
s.push(':');
s.push_str(<.count.to_string());
let mut ms: Vec<&String> = lt.methods.iter().collect();
ms.sort();
for mname in ms {
s.push(';');
s.push_str(mname);
}
}
s.push('\n');
nested_tf(&mut s, &m.trait_fields);
s.push_str("prim_aliases");
let mut pak: Vec<&String> = m.prim_aliases.iter().collect();
pak.sort();
for a in pak {
s.push('|');
s.push_str(a);
}
s.push('\n');
s.push_str("features");
for f in active_features_sorted() {
s.push('|');
s.push_str(&f);
}
s.push('\n');
fnv1a(s.as_bytes())
}
fn nested_tf(s: &mut String, map: &TraitFieldIndex) {
s.push_str("trait_fields");
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for k in keys {
s.push('|');
s.push_str(k);
let inner = &map[k];
let mut ik: Vec<&String> = inner.keys().collect();
ik.sort();
for f in ik {
s.push(';');
s.push_str(f);
s.push('=');
s.push_str(&inner[f].join(","));
}
}
s.push('\n');
}
#[derive(serde::Serialize, serde::Deserialize)]
struct FileCache {
content_hash: String,
decls: FileDecls,
decl_index_hash: String,
fninfos: Vec<FnInfo>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct ScanCache {
schema: String,
files: HashMap<String, FileCache>,
}
fn main() {
const BIG_STACK: usize = 256 * 1024 * 1024;
rayon::ThreadPoolBuilder::new().stack_size(BIG_STACK).build_global().ok();
let worker = std::thread::Builder::new()
.stack_size(BIG_STACK)
.spawn(scan_main)
.expect("spawn scan worker thread");
if worker.join().is_err() {
std::process::exit(101);
}
}
fn scan_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 policy_path: Option<String> = None;
let mut deps_mode = false;
let mut incremental = 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,
"--incremental" => incremental = true,
"--policy" => policy_path = it.next().cloned(),
"--deps" => deps_mode = true,
"-V" | "--version" => {
println!("candor-scan {} (spec {})", env!("CARGO_PKG_VERSION"), candor_report::SPEC_VERSION);
println!("upgrade: cargo install candor-scan --force");
return;
}
"--agents" => {
println!("<!-- candor-scan {} · the agent contract for this installed version -->", env!("CARGO_PKG_VERSION"));
print!("{}", include_str!("../AGENTS.md"));
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] [--policy <file>]");
println!();
println!(" <dir> crate root to scan (default: .). A [workspace] root scans");
println!(" every member: one report per member under the one prefix.");
println!(" A nested dir with its own Cargo.toml is a different package");
println!(" and is never folded into the parent's report.");
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!(" --incremental reuse a per-file parse/decl cache under <dir>/.candor/cache so an");
println!(" edit-then-rescan skips re-parsing unchanged files (~7x on a one-file");
println!(" edit). Produces a BYTE-IDENTICAL report to a full scan; a candor-scan");
println!(" upgrade or a decl-changing edit invalidates the cache automatically.");
println!(" --deps scan the Cargo.lock dependency tree first (registry sources from");
println!(" ~/.cargo/registry/src) into <dir>/.candor/deps/, then scan <dir>");
println!(" CHAINED over those reports — effects cross every crate boundary");
println!(" without κ needing to know the crates.");
println!(" --policy <file> enforce a CANDOR_POLICY file (deny/pure/allow/forbid, spec §6.2)");
println!(" over this scan; exit 1 on violation. ADVISORY FLOOR: the syntactic");
println!(" backend under-reports, so a miss can pass — the nightly engine is");
println!(" the sound gate. (CANDOR_POLICY env is honoured when flag absent.)");
println!();
println!(" CANDOR_DEPS=<p:…> chain sibling reports (files or directories of *.json): an");
println!(" unclassified call into a crate a report covers inherits that");
println!(" function's effects + literal surfaces (spec §2). Scan the dep");
println!(" once, chain it everywhere; the κ ledger names what to scan next.");
println!(" -V, --version print the installed build + spec contract (offline) and the upgrade line");
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;
}
other => {
if other.starts_with('-') {
eprintln!("candor-scan: unknown flag '{other}' (see --help)");
std::process::exit(2);
}
dir = a.clone();
}
}
}
let policy = policy_path.or_else(|| std::env::var("CANDOR_POLICY").ok());
if deps_mode {
std::process::exit(run_with_deps(&dir, prefix, want_json, include_tests, policy));
}
INCREMENTAL.with(|c| c.set(incremental));
let deps_idx = load_dep_reports(std::env::var("CANDOR_DEPS").ok().as_deref());
std::process::exit(scan_target(&dir, prefix, want_json, include_tests, policy, &deps_idx));
}
struct ScanOpts<'a> {
prefix: String,
want_json: bool,
include_tests: bool,
policy: Option<String>,
quiet: bool,
deps_idx: &'a DepIndex,
}
fn scan_one(dir: &str, opts: ScanOpts) -> (i32, Option<String>) {
let ScanOpts { prefix, want_json, include_tests, policy: policy_path, quiet, deps_idx } = opts;
let root = Path::new(dir);
let crate_name = read_crate_name(root).unwrap_or_else(|| "crate".to_string());
set_cfg_features(parse_features(root));
let mut paths: Vec<(std::path::PathBuf, String)> = Vec::new();
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 || !e.file_type().is_dir() {
return true;
}
let name = e.file_name().to_str().unwrap_or("");
if name == "target" || (name.starts_with('.') && name != "." && name != "..") {
return false;
}
!e.path().join("Cargo.toml").is_file()
})
.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 is_build_script(rel) {
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;
}
}
}
paths.push((p.to_path_buf(), rel.to_string_lossy().into_owned()));
}
use rayon::prelude::*;
let incremental = INCREMENTAL.with(|c| c.get());
let schema = cache_schema(include_tests);
let cache_dir = Path::new(dir).join(".candor").join("cache");
let cache_path = cache_dir.join("scan-cache.json");
let mut prior: HashMap<String, FileCache> = if incremental {
std::fs::read(&cache_path)
.ok()
.and_then(|b| serde_json::from_slice::<ScanCache>(&b).ok())
.filter(|c| c.schema == schema)
.map(|c| c.files)
.unwrap_or_default()
} else {
HashMap::new()
};
let hashes: Vec<(String, String)> = paths
.par_iter()
.map(|(p, rel)| (rel.clone(), std::fs::read(p).map(|b| fnv1a(&b)).unwrap_or_default()))
.collect();
let per_file: Vec<(String, String, Option<FileCache>)> = hashes
.into_iter()
.map(|(rel, content_hash)| {
let cached = prior
.remove(&rel)
.filter(|fc| fc.content_hash == content_hash);
(rel, content_hash, cached)
})
.collect();
let round1: Vec<Option<ParsedFile>> = per_file
.par_iter()
.map(|(rel, _, cached)| {
if cached.is_some() {
return None; }
let p = &paths.iter().find(|(_, r)| r == rel)?.0;
let text = std::fs::read_to_string(p).ok()?;
let file = syn::parse_file(&text).ok()?;
let mut locs = Vec::new();
fn_locs(&file.items, rel, include_tests, &mut locs);
Some((SendFile(file), locs))
})
.collect();
let unparsed: Vec<&str> = per_file
.iter()
.zip(&round1)
.filter(|(pf, parsed)| pf.2.is_none() && parsed.is_none())
.map(|(pf, _)| pf.0.as_str())
.collect();
if !unparsed.is_empty() {
let shown = unparsed.iter().take(8).copied().collect::<Vec<_>>().join(", ");
let more = if unparsed.len() > 8 { format!(" + {} more", unparsed.len() - 8) } else { String::new() };
eprintln!(
"candor-scan: {} source file(s) failed to read/parse — effects in them are NOT in this report (re-check the source): {shown}{more}",
unparsed.len()
);
}
let mut decls_per_file: Vec<(String, String, FileDecls)> = Vec::new(); let mut parsed_files: HashMap<String, syn::File> = HashMap::new(); let mut parsed_locs: HashMap<String, Vec<String>> = HashMap::new(); let mut cached_fninfos: HashMap<String, (String, Vec<FnInfo>)> = HashMap::new(); let mut disk_decl_hash: HashMap<String, String> = HashMap::new();
for ((rel, ch, cached), r1) in per_file.into_iter().zip(round1) {
match cached {
Some(fc) => {
disk_decl_hash.insert(rel.clone(), fc.decl_index_hash.clone());
decls_per_file.push((rel.clone(), ch, fc.decls));
cached_fninfos.insert(rel, (fc.decl_index_hash, fc.fninfos));
}
None => {
let Some((sf, locs)) = r1 else { continue };
let fd = file_decls(&sf.0.items, include_tests);
decls_per_file.push((rel.clone(), ch, fd));
parsed_locs.insert(rel.clone(), locs);
parsed_files.insert(rel, sf.0);
}
}
}
let mut merged = MergedDecls::default();
for (_, _, fd) in &decls_per_file {
merge_decls(&mut merged, fd);
}
let decl_index_hash = decl_index_digest(&merged);
let returns: ReturnIndex =
merged.rets.iter().filter_map(|(k, v)| v.clone().map(|t| (k.clone(), t))).collect();
let enum_variants: EnumVariantIndex =
merged.enum_tmp.iter().filter_map(|(k, v)| v.clone().map(|t| (k.clone(), t))).collect();
let fields = &merged.fields;
let field_elem = &merged.field_elem;
let trait_impls = &merged.trait_impls;
let trait_decls = &merged.trait_decls;
let trait_fields = &merged.trait_fields;
let traits = TraitIndexes { impls: trait_impls, decls: trait_decls, fields: trait_fields };
let elems = ElemIndexes { field_elem, enum_variants: &enum_variants };
let need_passb: Vec<&str> = decls_per_file
.iter()
.map(|(rel, _, _)| rel.as_str())
.filter(|rel| {
!parsed_files.contains_key(*rel)
&& cached_fninfos.get(*rel).map(|(h, _)| h != &decl_index_hash).unwrap_or(true)
})
.collect();
let round2: Vec<(String, Option<ParsedFile>)> = need_passb
.par_iter()
.map(|rel| {
let parsed = paths
.iter()
.find(|(_, r)| r == rel)
.and_then(|(p, _)| std::fs::read_to_string(p).ok())
.and_then(|t| syn::parse_file(&t).ok())
.map(|file| {
let mut locs = Vec::new();
fn_locs(&file.items, rel, include_tests, &mut locs);
(SendFile(file), locs)
});
(rel.to_string(), parsed)
})
.collect();
for (rel, sf) in round2 {
if let Some((sf, locs)) = sf {
parsed_locs.insert(rel.clone(), locs);
parsed_files.insert(rel, sf.0);
}
}
let mut fns: Vec<FnInfo> = Vec::new();
let mut fresh_fninfos: HashMap<String, Vec<FnInfo>> = HashMap::new();
for (rel, _, _) in &decls_per_file {
let reuse = cached_fninfos
.get(rel)
.filter(|(h, _)| *h == decl_index_hash)
.map(|(_, v)| v.clone());
if let Some(v) = reuse {
fns.extend(v.iter().cloned());
continue;
}
let Some(file) = parsed_files.get(rel) else { continue };
let modpath = module_path(Path::new(rel));
let locs = parsed_locs.get(rel).map(Vec::as_slice).unwrap_or(&[]);
let mut loc_idx = 0usize;
let mut uses = HashMap::new();
let mut file_fns: Vec<FnInfo> = Vec::new();
scan_items(&file.items, &modpath, locs, &mut loc_idx, include_tests, fields, &returns, traits, elems, &mut uses, &mut file_fns);
fns.extend(file_fns.iter().cloned());
fresh_fninfos.insert(rel.clone(), file_fns);
}
if incremental {
let unchanged = fresh_fninfos.is_empty()
&& prior.is_empty() && decls_per_file.iter().all(|(rel, _, _)| disk_decl_hash.get(rel) == Some(&decl_index_hash));
if !unchanged {
let mut files: HashMap<String, FileCache> = HashMap::with_capacity(decls_per_file.len());
for (rel, ch, fd) in &decls_per_file {
let fninfos = fresh_fninfos
.get(rel)
.cloned()
.or_else(|| cached_fninfos.get(rel).map(|(_, v)| v.clone()))
.unwrap_or_default();
files.insert(
rel.clone(),
FileCache {
content_hash: ch.clone(),
decls: fd.clone(),
decl_index_hash: decl_index_hash.clone(),
fninfos,
},
);
}
let cache = ScanCache { schema: schema.clone(), files };
let _ = std::fs::create_dir_all(&cache_dir);
if let Ok(bytes) = serde_json::to_vec(&cache) {
let _ = candor_report::write_atomic(&cache_path, &bytes);
}
}
}
let (deps, dep_renames) = cargo_deps(dir);
let mut dep_seen: HashMap<String, usize> = HashMap::new(); let mut dep_classified: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
let mut local_types: std::collections::HashSet<String> = std::collections::HashSet::new();
for f in &fns {
by_leaf.entry(f.leaf.clone()).or_default().push(f.qual.clone());
if let Some(t2) = tail2(&f.qual) {
if let Some(ty) = t2.split("::").next() {
if ty.chars().next().is_some_and(|c| c.is_uppercase()) {
local_types.insert(ty.to_string());
}
}
by_tail2.entry(t2).or_default().push(f.qual.clone());
}
}
let mut type_to_traits: HashMap<String, Vec<String>> = HashMap::new();
for (tr, types) in &merged.trait_impls {
let tr_leaf = tr.rsplit("::").next().unwrap_or(tr).to_string();
for ty in types {
let ty_leaf = ty.rsplit("::").next().unwrap_or(ty).to_string();
type_to_traits.entry(ty_leaf).or_default().push(tr_leaf.clone());
}
}
let local_method_leaves: std::collections::HashSet<String> = fns.iter()
.filter_map(|f| tail2(&f.qual))
.filter(|t2| t2.split("::").next().is_some_and(|ty| ty.chars().next().is_some_and(char::is_uppercase)))
.filter_map(|t2| t2.rsplit("::").next().map(str::to_string))
.collect();
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 tables: 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());
if f.unresolved {
direct.entry(f.qual.clone()).or_default().insert("Unknown");
}
for c in &f.calls {
let cr = c.path.split("::").next().unwrap_or("");
let classified = candor_classify::classify(cr, &c.path);
if c.path.contains("::") && deps.contains(cr) {
*dep_seen.entry(cr.to_string()).or_insert(0) += 1;
if classified.is_some() {
dep_classified.insert(cr.to_string());
}
}
let cr_real: &str = dep_renames.get(cr).map(String::as_str).unwrap_or(cr);
if classified.is_none() && c.path.contains("::") && deps_idx.crates.contains(cr_real) {
let rel = c.path.strip_prefix(&format!("{cr}::")).unwrap_or(&c.path);
let hit = if rel.contains("::") {
tail2(rel).and_then(|t2| deps_idx.by_key.get(&format!("{cr_real}#{t2}")))
} else {
deps_idx.by_key.get(&format!("{cr_real}#{rel}"))
};
if let Some(de) = hit {
for e in &de.effects {
direct.entry(f.qual.clone()).or_default().insert(e);
}
hosts.entry(f.qual.clone()).or_default().extend(de.hosts.iter().cloned());
cmds.entry(f.qual.clone()).or_default().extend(de.cmds.iter().cloned());
paths.entry(f.qual.clone()).or_default().extend(de.paths.iter().cloned());
tables.entry(f.qual.clone()).or_default().extend(de.tables.iter().cloned());
dep_classified.insert(cr.to_string());
}
}
let resolvable = if c.typed {
tail2(&c.path)
.and_then(|t2| t2.split("::").next().map(str::to_string))
.is_some_and(|ty| local_types.contains(&ty))
} else {
!matches!(cr, "std" | "core" | "alloc")
};
let aliased = tail2(&c.path)
.and_then(|t2| t2.split("::").next().map(str::to_string))
.is_some_and(|ty| merged.prim_aliases.contains(&ty));
let mut resolved_local = false;
if resolvable && !aliased {
let targets = resolve_target(&c.path, &c.leaf, c.method, &by_tail2, &by_leaf);
if let Some(targets) = targets {
resolved_local = true;
for t in targets {
if t != &f.qual {
calls.entry(f.qual.clone()).or_default().insert(t.clone());
}
}
} else if c.method && c.typed {
if let Some(t_type) = tail2(&c.path).and_then(|t2| t2.split("::").next().map(str::to_string)) {
if let Some(trs) = type_to_traits.get(&t_type) {
let mut hits: Vec<&String> = Vec::new();
for tr_leaf in trs {
if let Some(ts) = by_tail2.get(&format!("{tr_leaf}::{}", c.leaf)) {
for t in ts {
if !hits.contains(&t) {
hits.push(t);
}
}
}
}
if hits.len() == 1 && hits[0] != &f.qual {
resolved_local = true;
calls.entry(f.qual.clone()).or_default().insert(hits[0].clone());
}
}
}
}
}
let suppress_bare_leaf =
c.method && !c.path.contains("::") && local_method_leaves.contains(&c.leaf);
if let Some(eff) = classified.filter(|_| !suppress_bare_leaf && !resolved_local) {
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" => {
if candor_classify::is_cmd_naming_method(c.path.rsplit("::").next().unwrap_or("")) {
cmds.entry(f.qual.clone()).or_default().insert(s.clone());
direct.entry(f.qual.clone()).or_default()
.extend(candor_classify::classify_command_head(s).iter().copied());
}
}
"Fs" => { paths.entry(f.qual.clone()).or_default().insert(s.clone()); }
"Db" => { tables.entry(f.qual.clone()).or_default().extend(candor_classify::tables_in_sql(s)); }
_ => {}
}
}
}
}
}
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 tablesacc = propagate_str(&tables, &calls, &all);
let mut entries: Vec<ReportEntry> = Vec::new();
let mut cg: BTreeMap<String, Vec<String>> = BTreeMap::new();
for q in &all {
cg.insert(q.clone(), calls.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default());
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: inf.contains("Unknown"),
hash: format!("{crate_name}#{q}"),
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(),
tables: tablesacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
calls: calls.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default(),
unknown_why: if direct.get(q).is_some_and(|d| d.contains("Unknown")) {
vec!["callback:unresolved call".to_string()]
} else {
Vec::new()
},
entry_point: q.rsplit("::").next() == Some("main"),
});
}
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(),
spec: candor_report::SPEC_VERSION.into(),
};
let body = candor_report::to_packaged_report_json(&meta, &crate_name, &entries).unwrap_or_default();
let json_body = if want_json {
Some(body.clone())
} 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 _ = candor_report::write_atomic(Path::new(&file), body.as_bytes());
let cgfile = format!("{prefix}.{crate_name}.scan.callgraph.json");
let _ = candor_report::write_atomic(Path::new(&cgfile), serde_json::to_string(&cg).unwrap_or_default().as_bytes());
if !quiet {
eprintln!(
"candor-scan: wrote {} effectful functions to {file} (stable syntactic backend — see --help)",
entries.len()
);
}
None
};
let mut unlisted: Vec<(&String, usize)> = dep_seen
.iter()
.filter(|(cr, _)| {
!dep_classified.contains(*cr)
&& !deps_idx.crates.contains(dep_renames.get(cr.as_str()).map(String::as_str).unwrap_or(cr.as_str()))
&& !candor_classify::CALIBRATED_CRATES.contains(&cr.as_str())
&& !candor_classify::PATH_CALIBRATED_CRATES.contains(&cr.as_str())
&& !candor_classify::CALIBRATED_PREFIXES.iter().any(|p| cr.starts_with(p))
})
.map(|(cr, n)| (cr, *n))
.collect();
if !unlisted.is_empty() && !quiet {
unlisted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let shown: Vec<String> =
unlisted.iter().take(8).map(|(cr, n)| format!("{cr} ({n} call{})", if *n == 1 { "" } else { "s" })).collect();
let more = if unlisted.len() > 8 { format!(" + {} more", unlisted.len() - 8) } else { String::new() };
eprintln!(
"candor-scan: κ doesn't know {} dependenc{} this code calls into — effects through {} are INVISIBLE (not Unknown): {}{}",
unlisted.len(),
if unlisted.len() == 1 { "y" } else { "ies" },
if unlisted.len() == 1 { "it" } else { "them" },
shown.join(", "),
more
);
}
if let Some(pp) = policy_path {
let Ok(text) = std::fs::read_to_string(&pp) else {
eprintln!("candor-scan: policy {pp:?} could not be read; gate NOT enforced");
return (2, json_body);
};
let v = policy_violations(&text, &all, &inferred, &calls, &hostsacc, &cmdsacc, &pathsacc, &tablesacc);
for line in &v {
println!("{line}");
}
if v.is_empty() {
eprintln!("candor-scan: policy ✓ (advisory floor — the syntactic backend under-reports; the nightly engine is the sound gate)");
} else {
eprintln!("candor-scan: {} policy violation(s) (advisory floor — a clean run is necessary, not sufficient)", v.len());
return (1, json_body);
}
}
(0, json_body)
}
fn scan_target(
dir: &str,
prefix: String,
want_json: bool,
include_tests: bool,
policy: Option<String>,
deps_idx: &DepIndex,
) -> i32 {
let members = workspace_members(Path::new(dir));
if members.is_empty() {
if has_workspace_table(Path::new(dir)) {
eprintln!("candor-scan: `{dir}` declares [workspace] but no members resolved — \
check `members`/globs; scan member crates directly to gate them");
}
let (code, json) = scan_one(dir, ScanOpts {
prefix, want_json, include_tests, policy, quiet: false, deps_idx,
});
if let Some(b) = json {
println!("{b}");
}
return code;
}
let prefix = if prefix.is_empty() { format!("{dir}/.candor/report") } else { prefix };
let mut dirs: Vec<String> = Vec::new();
if read_crate_name(Path::new(dir)).is_some() {
dirs.push(dir.to_string()); }
dirs.extend(members);
let mut rc = 0;
let mut bodies: Vec<String> = Vec::new();
for d in &dirs {
let (code, json) = scan_one(d, ScanOpts {
prefix: prefix.clone(), want_json, include_tests, policy: policy.clone(), quiet: false, deps_idx,
});
rc = rc.max(code);
if let Some(b) = json {
bodies.push(b);
}
}
if want_json {
println!("[{}]", bodies.join(","));
} else {
eprintln!("candor-scan: workspace — {} package report(s) under one prefix", dirs.len());
}
rc
}
fn run_with_deps(dir: &str, prefix: String, want_json: bool, include_tests: bool, policy: Option<String>) -> i32 {
let lock = match std::fs::read_to_string(format!("{dir}/Cargo.lock")) {
Ok(t) => t,
Err(_) => {
eprintln!("candor-scan: --deps needs {dir}/Cargo.lock (run `cargo generate-lockfile` first)");
return 2;
}
};
let mut pkgs: Vec<(String, String)> = Vec::new();
let (mut name, mut version, mut registry) = (String::new(), String::new(), false);
let flush = |name: &mut String, version: &mut String, registry: &mut bool, pkgs: &mut Vec<(String, String)>| {
if *registry && !name.is_empty() && !version.is_empty() {
pkgs.push((name.clone(), version.clone()));
}
name.clear();
version.clear();
*registry = false;
};
for line in lock.lines() {
let l = line.trim();
if l == "[[package]]" {
flush(&mut name, &mut version, &mut registry, &mut pkgs);
} else if let Some(v) = l.strip_prefix("name = ") {
name = v.trim_matches('"').to_string();
} else if let Some(v) = l.strip_prefix("version = ") {
version = v.trim_matches('"').to_string();
} else if l.starts_with("source = ") && l.contains("registry+") {
registry = true;
}
}
flush(&mut name, &mut version, &mut registry, &mut pkgs);
let registry_roots: Vec<std::path::PathBuf> = dirs_cargo_registry_src();
let deps_dir = format!("{dir}/.candor/deps");
let _ = std::fs::create_dir_all(&deps_dir);
let (mut scanned, mut cached, mut missing) = (0usize, 0usize, Vec::new());
let no_deps = DepIndex::default();
for (n, v) in &pkgs {
let Some(src) = registry_roots.iter().map(|r| r.join(format!("{n}-{v}"))).find(|p| p.is_dir()) else {
missing.push(format!("{n}-{v}"));
continue;
};
let sub = format!("{deps_dir}/{n}@{v}");
let already = std::fs::read_dir(&sub).ok().is_some_and(|rd| {
rd.flatten().any(|e| {
let f = e.file_name();
let f = f.to_string_lossy();
f.ends_with(".scan.json") && !f.contains("callgraph")
})
});
if already {
cached += 1; continue;
}
let _ = std::fs::create_dir_all(&sub);
let _ = scan_one(&src.to_string_lossy(), ScanOpts {
prefix: format!("{sub}/report"),
want_json: false,
include_tests: false,
policy: None,
quiet: true,
deps_idx: &no_deps,
});
scanned += 1;
}
eprintln!(
"candor-scan: --deps scanned {scanned} of {} registry dependencies into {deps_dir}{}{} \
(floor-engine reports: a dep's silent misses pass through — the κ caveat applies to the chain too)",
pkgs.len(),
if cached > 0 { format!(" ({cached} already scanned — cached)") } else { String::new() },
if missing.is_empty() {
String::new()
} else {
format!(" ({} without a local checkout: {}{})", missing.len(),
missing.iter().take(5).cloned().collect::<Vec<_>>().join(", "),
if missing.len() > 5 { ", …" } else { "" })
}
);
let spec = match std::env::var("CANDOR_DEPS") {
Ok(extra) if !extra.is_empty() => format!("{deps_dir}:{extra}"),
_ => deps_dir.clone(),
};
let idx = load_dep_reports(Some(&spec));
scan_target(dir, prefix, want_json, include_tests, policy, &idx)
}
fn dirs_cargo_registry_src() -> Vec<std::path::PathBuf> {
let home = std::env::var("CARGO_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| Path::new(&std::env::var("HOME").unwrap_or_default()).join(".cargo"));
std::fs::read_dir(home.join("registry").join("src"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
}
#[allow(clippy::too_many_arguments)]
fn policy_violations(
policy_text: &str,
all: &[String],
inferred: &HashMap<String, BTreeSet<&'static str>>,
calls: &HashMap<String, BTreeSet<String>>,
hostsacc: &HashMap<String, BTreeSet<String>>,
cmdsacc: &HashMap<String, BTreeSet<String>>,
pathsacc: &HashMap<String, BTreeSet<String>>,
tablesacc: &HashMap<String, BTreeSet<String>>,
) -> Vec<String> {
use candor_classify::policy::{literal_allowed, parse_policy, scope_matches};
let p = parse_policy(policy_text);
let empty: BTreeSet<&'static str> = BTreeSet::new();
let mut out = Vec::new();
for q in all {
let inf = inferred.get(q).unwrap_or(&empty);
for r in &p.rules {
if let Some(s) = &r.scope {
if !scope_matches(q, s) {
continue;
}
}
let hits: Vec<&str> = if r.effects.is_empty() {
inf.iter().copied().collect() } else {
inf.iter().copied().filter(|e| r.effects.contains(e)).collect()
};
if !hits.is_empty() {
out.push(format!("[AS-EFF-006] `{q}` performs {{ {} }}, forbidden by policy: `{}`", hits.join(", "), r.raw));
}
}
for r in &p.allow_rules {
if let Some(s) = &r.scope {
if !scope_matches(q, s) {
continue;
}
}
if !inf.contains(r.effect) {
continue;
}
let lits = match r.effect {
"Net" => hostsacc.get(q),
"Exec" => cmdsacc.get(q),
"Db" => tablesacc.get(q),
_ => pathsacc.get(q),
};
match lits {
Some(ls) if !ls.is_empty() => {
let bad: Vec<&str> =
ls.iter().filter(|l| !literal_allowed(r.effect, l, &r.literals)).map(String::as_str).collect();
if !bad.is_empty() {
out.push(format!("[AS-EFF-008] `{q}` reaches {{ {} }} outside the allowlist: `{}`", bad.join(", "), r.raw));
}
}
_ => out.push(format!(
"[AS-EFF-008] `{q}` performs {} with no visible literal — the surface cannot be certified: `{}`",
r.effect, r.raw
)),
}
}
for r in &p.layer_rules {
if !scope_matches(q, &r.from) {
continue;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> = calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
let mut hit: Option<&str> = None;
while let Some(n) = stack.pop() {
if !seen.insert(n) {
continue;
}
if scope_matches(n, &r.to) {
hit = Some(n);
break;
}
if let Some(cs) = calls.get(n) {
stack.extend(cs.iter().map(String::as_str));
}
}
if let Some(h) = hit {
out.push(format!("[AS-EFF-009] `{q}` reaches into a forbidden layer (via `{h}`): `{}`", r.raw));
}
}
}
out.sort();
out
}
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 resolve_target<'a>(
path: &str,
leaf: &str,
method: bool,
by_tail2: &'a HashMap<String, Vec<String>>,
by_leaf: &'a HashMap<String, Vec<String>>,
) -> Option<&'a Vec<String>> {
if path.contains("::") {
tail2(path).and_then(|t2| by_tail2.get(&t2)).filter(|v| v.len() == 1)
} else if method {
None
} else {
by_leaf.get(leaf).filter(|v| v.len() == 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()?;
let mut in_package = false;
for line in txt.lines() {
if let Some(section) = toml_section(line) {
in_package = section == "package"; continue;
}
if in_package {
if let Some(v) = toml_scalar(line, "name") {
return Some(v.replace('-', "_"));
}
}
}
None
}
fn toml_string_array(txt: &str, table: &str, key: &str) -> Vec<String> {
let (mut in_table, mut collecting) = (false, false);
let mut out = Vec::new();
for line in txt.lines() {
let l = line.trim();
if !collecting {
if let Some(section) = toml_section(line) {
in_table = section == table;
continue;
}
}
if !in_table {
continue;
}
let rest = if let Some(r) = l.strip_prefix(key) {
let r = r.trim_start();
let Some(r) = r.strip_prefix('=') else { continue };
collecting = true;
r
} else if collecting {
l
} else {
continue;
};
let mut parts = rest.split('"');
parts.next();
while let Some(s) = parts.next() {
out.push(s.to_string());
if parts.next().is_none() {
break;
}
}
if rest.contains(']') {
collecting = false;
}
}
out
}
fn has_workspace_table(root: &Path) -> bool {
std::fs::read_to_string(root.join("Cargo.toml"))
.map(|t| t.lines().any(|l| l.trim() == "[workspace]"))
.unwrap_or(false)
}
fn workspace_members(root: &Path) -> Vec<String> {
let Ok(txt) = std::fs::read_to_string(root.join("Cargo.toml")) else { return Vec::new() };
let members = toml_string_array(&txt, "workspace", "members");
if members.is_empty() {
return Vec::new();
}
let exclude = toml_string_array(&txt, "workspace", "exclude");
let expand = |base: &str| -> Vec<String> {
let dir = if base.is_empty() { root.to_path_buf() } else { root.join(base) };
let mut found: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(Result::ok)
.filter(|e| e.path().join("Cargo.toml").is_file())
.map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
if base.is_empty() { n } else { format!("{base}/{n}") }
})
.collect();
found.sort();
found
};
let mut rels: Vec<String> = Vec::new();
for m in members {
if m == "*" {
rels.extend(expand(""));
} else if let Some(base) = m.strip_suffix("/*") {
rels.extend(expand(base));
} else if m.contains('*') {
eprintln!("candor-scan: workspace member glob `{m}` is not a trailing `*` — not expanded; \
scan its crates directly or list them explicitly");
} else if root.join(&m).join("Cargo.toml").is_file() {
rels.push(m);
}
}
rels.retain(|m| !exclude.iter().any(|e| m == e || m.starts_with(&format!("{e}/"))));
rels.sort();
rels.dedup();
rels.into_iter().map(|m| root.join(m).to_string_lossy().into_owned()).collect()
}
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"));
let mut o2 = HashMap::new();
collect_use(&syn::parse_str("std::fs::{self, Metadata}").unwrap(), String::new(), &mut o2);
assert_eq!(o2.get("fs").map(String::as_str), Some("std::fs"));
assert_eq!(o2.get("Metadata").map(String::as_str), Some("std::fs::Metadata"));
assert_eq!(o2.get("self"), None); }
#[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");
assert_eq!(
module_path(Path::new("src/generated/envoy.service.auth.v3.rs")),
"generated::envoy::service::auth::v3"
);
assert_eq!(module_path(Path::new("crates/cli/src/decompress.rs")), "decompress");
assert_eq!(module_path(Path::new("crates/ignore/src/walk.rs")), "walk");
assert_eq!(module_path(Path::new("crates/core/src/main.rs")), "");
}
#[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());
}
assert_eq!(resolve_target("api::RequestBuilder::new", "new", false, &by_tail2, &by_leaf),
Some(&vec!["http::RequestBuilder::new".to_string()]));
assert_eq!(resolve_target("new", "new", true, &by_tail2, &by_leaf), 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 (ti, td, tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let (fe, ev) = (FieldElemIndex::new(), EnumVariantIndex::new());
let mut c = CallCollector {
uses: &uses,
vars: HashMap::new(),
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
field_elem: &fe,
enum_variants: &ev,
elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
fn_typed_vars: std::collections::HashSet::new(),
unresolved: false,
};
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 (ti, td, tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let (fe, ev) = (FieldElemIndex::new(), EnumVariantIndex::new());
let block: syn::Block =
syn::parse_str("{ client.get(url).send(); self.http.execute(req); }").unwrap();
let mut c = CallCollector {
uses: &uses,
vars,
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
field_elem: &fe,
enum_variants: &ev,
elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
fn_typed_vars: std::collections::HashSet::new(),
unresolved: false,
};
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 cargo_toml_deps_handles_all_header_forms() {
let mut out = std::collections::HashSet::new();
let mut renames = HashMap::new();
cargo_toml_deps(
"[package]\nname = \"x\"\n\n[dependencies]\nserde_json = \"1\"\nleft-pad = \"1\"\n\n[dependencies.table-header]\nversion = \"1\"\n\n[target.'cfg(unix)'.dependencies.nix]\nversion = \"0.29\"\n\n[target.'cfg(windows)'.dependencies]\nwinapi = \"0.3\"\n\n[workspace.dependencies]\nshared-dep = \"2\"\n\n[dev-dependencies]\ncriterion = \"0.5\"\n\n[build-dependencies]\ncc = \"1\"\n\n[dev-dependencies.proptest]\nversion = \"1\"\n",
&mut out,
&mut renames,
);
for d in ["serde_json", "left_pad", "table_header", "nix", "winapi", "shared_dep"] {
assert!(out.contains(d), "missing {d}: {out:?}");
}
for d in ["criterion", "cc", "proptest", "x"] {
assert!(!out.contains(d), "harness/package dep leaked: {d}");
}
let mut out2 = std::collections::HashSet::new();
let mut ren2 = HashMap::new();
cargo_toml_deps(
"[dependencies]\ntui-common = { version = \"0.1\", package = \"tb-tui-common\" }\n\n[dependencies.short-name]\nversion = \"1\"\npackage = \"the-real-crate\"\n",
&mut out2,
&mut ren2,
);
assert!(out2.contains("tui_common") && out2.contains("short_name"), "{out2:?}");
assert_eq!(ren2.get("tui_common").map(String::as_str), Some("tb_tui_common"));
assert_eq!(ren2.get("short_name").map(String::as_str), Some("the_real_crate"));
let mut out3 = std::collections::HashSet::new();
let mut ren3 = HashMap::new();
cargo_toml_deps(
"[dependencies]\nmy-package = \"1.2\"\nfoo-package = { version = \"2\" }\npackage = \"0.1\"\nreal = { version = \"1\", package = \"renamed-crate\" }\n",
&mut out3,
&mut ren3,
);
assert!(out3.contains("my_package") && out3.contains("foo_package") && out3.contains("package"));
assert!(!ren3.contains_key("my_package"), "key-substring 'package' fabricated a rename: {ren3:?}");
assert!(!ren3.contains_key("foo_package"), "{ren3:?}");
assert!(!ren3.contains_key("package"), "a dep named `package` is not a rename: {ren3:?}");
assert_eq!(ren3.get("real").map(String::as_str), Some("renamed_crate"), "real rename lost: {ren3:?}");
}
#[test]
fn dep_report_chaining_joins_unambiguously_and_distrusts_stale_versions() {
let d = std::env::temp_dir().join(format!("candor-deps-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&d);
let me = format!("scan-{}", env!("CARGO_PKG_VERSION"));
std::fs::write(d.join("report.billing.scan.json"), format!(r#"{{
"candor": {{"version": "{me}", "toolchain": "stable", "spec": "0.3"}},
"functions": [
{{"fn": "ledger::Ledger::post", "inferred": ["Db"], "tables": ["ledger.entries"], "hash": "billing#ledger::Ledger::post"}},
{{"fn": "a::dup", "inferred": ["Net"], "hash": "billing#a::dup"}},
{{"fn": "b::dup", "inferred": ["Fs"], "hash": "billing#b::dup"}}
]}}"#)).unwrap();
std::fs::write(d.join("report.old_dep.scan.json"), r#"{
"candor": {"version": "scan-0.0.1", "toolchain": "stable", "spec": "0.3"},
"functions": [{"fn": "io::go", "inferred": ["Exec"], "hash": "old_dep#io::go"}]}"#).unwrap();
let idx = load_dep_reports(Some(d.to_str().unwrap()));
assert!(idx.crates.contains("billing") && idx.crates.contains("old_dep"));
let post = idx.by_key.get("billing#Ledger::post").expect("tail2 key");
assert_eq!(post.effects, vec!["Db"]);
assert_eq!(post.tables, vec!["ledger.entries"]);
assert!(idx.by_key.contains_key("billing#post"), "unambiguous leaf key");
assert!(!idx.by_key.contains_key("billing#dup"), "shared leaf must be dropped, never guessed");
assert!(idx.by_key.contains_key("billing#a::dup"), "tail2 still disambiguates the dups");
let old = idx.by_key.get("old_dep#go").expect("stale entry present");
assert_eq!(old.effects, vec!["Unknown"], "stale version must downgrade to Unknown");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn dispatch_typed_receivers_resolve_via_local_impls_or_read_unknown() {
let uses = HashMap::new();
let fields = FieldIndex::new();
let returns = ReturnIndex::new();
let mut ti = TraitImplIndex::new();
ti.insert("Store".into(), vec!["PgStore".into(), "MemStore".into()]);
let mut td: HashMap<String, LocalTrait> = HashMap::new();
td.insert("Store".into(), LocalTrait { count: 1, methods: ["save".to_string()].into_iter().collect() });
td.insert("Sink".into(), LocalTrait { count: 1, methods: ["flush".to_string()].into_iter().collect() }); let mut tf = TraitFieldIndex::new();
tf.entry("App".into()).or_default().insert("store".into(), vec!["Store".into()]);
let (fe, ev) = (FieldElemIndex::new(), EnumVariantIndex::new());
let run = |src: &str, sig: &str| {
let sig: syn::Signature = syn::parse_str(sig).unwrap();
let blk: syn::Block = syn::parse_str(src).unwrap();
let trait_vars = seed_trait_vars(&sig);
let mut vars = seed_vars(&sig, Some("App"), &uses);
for k in trait_vars.keys() {
vars.remove(k); }
vars.insert("self".to_string(), "App".to_string());
let mut c = CallCollector {
uses: &uses,
vars,
trait_vars,
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
field_elem: &fe,
enum_variants: &ev,
elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
fn_typed_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &blk.stmts {
c.visit_stmt(stmt);
}
(c.calls.iter().map(|c| c.path.clone()).collect::<Vec<_>>(), c.unresolved)
};
let (paths, unres) = run("{ t.save(x); }", "fn f(t: &dyn Store)");
assert!(paths.contains(&"PgStore::save".to_string()), "dyn param not CHA-resolved: {paths:?}");
assert!(paths.contains(&"MemStore::save".to_string()), "dyn param missed an impl: {paths:?}");
assert!(!unres, "narrow local dispatch must not read Unknown");
let (paths, _) = run("{ s.save(x); }", "fn f(s: impl Store)");
assert!(paths.contains(&"PgStore::save".to_string()), "impl-Trait param not resolved: {paths:?}");
let (paths, _) = run("{ x.save(y); }", "fn f<X: Store>(x: X)");
assert!(paths.contains(&"PgStore::save".to_string()), "generic bound not resolved: {paths:?}");
let (paths, _) = run("{ self.store.save(x); }", "fn f(&self)");
assert!(paths.contains(&"PgStore::save".to_string()), "trait-typed field not resolved: {paths:?}");
let (_, unres) = run("{ k.flush(); }", "fn f(k: &dyn Sink)");
assert!(unres, "local trait without impls must read Unknown, not silent-pure");
let (paths, unres) = run("{ it.next(); }", "fn f(it: impl Iterator)");
assert!(!unres && !paths.iter().any(|p| p.contains("::next")), "external trait must stay out: {paths:?}");
{
let mut ti2 = TraitImplIndex::new();
ti2.insert("Iterator".into(), vec!["RowIter".into()]);
let sig: syn::Signature = syn::parse_str("fn f(it: impl Iterator)").unwrap();
let blk: syn::Block = syn::parse_str("{ it.next(); }").unwrap();
let mut c = CallCollector {
uses: &uses, vars: HashMap::new(), trait_vars: seed_trait_vars(&sig),
fields: &fields, trait_fields: &tf, trait_impls: &ti2, local_traits: &td,
returns: &returns, field_elem: &fe, enum_variants: &ev, elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(), fn_typed_vars: std::collections::HashSet::new(), unresolved: false,
};
for stmt in &blk.stmts { c.visit_stmt(stmt); }
assert!(!c.calls.iter().any(|x| x.path == "RowIter::next"),
"external-trait local impl must not resolve (fabrication)");
assert!(!c.unresolved, "external trait must not flood Unknown either");
}
let (paths, unres) = run("{ t.clone(); }", "fn f(t: &dyn Store)");
assert!(!unres && !paths.iter().any(|p| p.ends_with("::clone")),
"undeclared method must neither edge nor flood: {paths:?}");
{
let wide = |n: usize, src: &str, sig: &str| {
let mut ti2 = TraitImplIndex::new();
ti2.insert("Store".into(), (0..n).map(|i| format!("S{i}")).collect());
let sig: syn::Signature = syn::parse_str(sig).unwrap();
let blk: syn::Block = syn::parse_str(src).unwrap();
let mut c = CallCollector {
uses: &uses, vars: HashMap::new(), trait_vars: seed_trait_vars(&sig),
fields: &fields, trait_fields: &tf, trait_impls: &ti2, local_traits: &td,
returns: &returns, field_elem: &fe, enum_variants: &ev, elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(), fn_typed_vars: std::collections::HashSet::new(), unresolved: false,
};
for stmt in &blk.stmts { c.visit_stmt(stmt); }
(c.calls.iter().filter(|x| x.typed).count(), c.unresolved)
};
let (edges, unres) = wide(12, "{ t.save(x); }", "fn f(t: &dyn Store)");
assert!(edges == 12 && !unres, "12 impls must resolve (the shared bound)");
let (edges, unres) = wide(13, "{ t.save(x); }", "fn f(t: &dyn Store)");
assert!(edges == 0 && unres, "13 impls must read Unknown, not resolve");
}
}
#[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 (ti, td, tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let (fe, ev) = (FieldElemIndex::new(), EnumVariantIndex::new());
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(),
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
field_elem: &fe,
enum_variants: &ev,
elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
fn_typed_vars: std::collections::HashSet::new(),
unresolved: false,
};
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 mk = |src: &str| {
let blk: syn::Block = syn::parse_str(src).unwrap();
let mut cc = CallCollector {
uses: &uses,
vars: HashMap::new(),
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
field_elem: &fe,
enum_variants: &ev,
elem_of: HashMap::new(), tuple_of: HashMap::new(),
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
fn_typed_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &blk.stmts {
cc.visit_stmt(stmt);
}
cc.unresolved
};
assert!(mk("{ (handlers[k])(); }"), "indexed callable must be unresolved");
assert!(mk("{ (self.cb)(); }"), "fn-pointer field call must be unresolved");
assert!(!mk("{ let g = |x: i32| x + 1; let _ = g(3); }"), "local closure body is visible — not unresolved");
assert!(!mk("{ helper(); other::thing(); }"), "ordinary path calls are not unresolved");
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 stable_policy_gate_evaluates_all_three_rule_kinds() {
let all = vec!["api::handle".to_string(), "db::run".to_string(), "ui::draw".to_string()];
let mut inferred: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
inferred.insert("api::handle".into(), ["Net"].into_iter().collect());
inferred.insert("db::run".into(), ["Db"].into_iter().collect());
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
calls.insert("ui::draw".into(), ["db::run".to_string()].into_iter().collect());
let mut hosts: HashMap<String, BTreeSet<String>> = HashMap::new();
hosts.insert("api::handle".into(), ["evil.example.com".to_string()].into_iter().collect());
let empty = HashMap::new();
let mut tables: HashMap<String, BTreeSet<String>> = HashMap::new();
tables.insert("db::run".into(), ["audit.log".to_string()].into_iter().collect());
let v = policy_violations(
"deny Net api\nallow Net in api good.example.com\nforbid ui -> db\n",
&all, &inferred, &calls, &hosts, &empty, &empty, &tables,
);
assert_eq!(v.len(), 3, "{v:?}");
assert!(v.iter().any(|l| l.contains("[AS-EFF-006]") && l.contains("api::handle")));
assert!(v.iter().any(|l| l.contains("[AS-EFF-008]") && l.contains("evil.example.com")));
assert!(v.iter().any(|l| l.contains("[AS-EFF-009]") && l.contains("ui::draw")));
assert!(policy_violations("deny Exec\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables).is_empty());
assert_eq!(policy_violations("pure db\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables).len(), 1);
let bad = policy_violations("allow Db in db ledger.*\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables);
assert_eq!(bad.len(), 1, "{bad:?}");
assert!(bad[0].contains("audit.log"));
assert!(policy_violations("allow Db in db audit.*\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables).is_empty());
}
#[test]
fn only_root_build_rs_is_the_build_script() {
use std::path::Path;
assert!(is_build_script(Path::new("build.rs")));
assert!(!is_build_script(Path::new("src/build.rs")));
assert!(!is_build_script(Path::new("src/foo/build.rs")));
assert!(!is_build_script(Path::new("build/mod.rs"))); }
#[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();
let yes3: syn::ItemMod =
syn::parse_str("#[cfg(any(all(test, unix), windows))] mod t {}").unwrap();
let prod1: syn::ItemMod = syn::parse_str("#[cfg(not(test))] mod prod {}").unwrap();
let prod2: syn::ItemMod = syn::parse_str("#[cfg(all(unix, not(test)))] mod prod {}").unwrap();
assert!(is_cfg_test(&yes1.attrs));
assert!(is_cfg_test(&yes2.attrs));
assert!(is_cfg_test(&yes3.attrs));
assert!(!is_cfg_test(&no1.attrs));
assert!(!is_cfg_test(&no2.attrs));
assert!(!is_cfg_test(&prod1.attrs), "cfg(not(test)) is production, not a test module");
assert!(!is_cfg_test(&prod2.attrs), "cfg(all(unix, not(test))) is production");
}
#[test]
fn expand_does_not_alias_a_crate_rooted_path() {
let u = uses(&[("config", "other::config")]);
assert_eq!(expand("crate::config::load", &u), "config::load");
assert_eq!(expand("self::config::load", &u), "config::load");
assert_eq!(expand("config::load", &u), "other::config::load");
}
#[test]
fn ctor_type_rejects_a_module_path_receiver() {
let u = HashMap::new();
let r = ReturnIndex::new();
let modcall: syn::Expr = syn::parse_str("serde_json::from_str(s)").unwrap();
assert_eq!(ctor_type(&modcall, &u, &r), None);
let typecall: syn::Expr = syn::parse_str("reqwest::Client::new()").unwrap();
assert_eq!(ctor_type(&typecall, &u, &r).as_deref(), Some("reqwest::Client"));
}
#[test]
fn struct_literal_bindings_infer_their_type() {
let u = HashMap::new();
let r = ReturnIndex::new();
let t = |src: &str| ctor_type(&syn::parse_str::<syn::Expr>(src).unwrap(), &u, &r);
assert_eq!(t("S").as_deref(), Some("S")); assert_eq!(t("S { a: 1 }").as_deref(), Some("S")); assert_eq!(t("m::S { a: 1 }").as_deref(), Some("m::S")); assert_eq!(t("Color::Red").as_deref(), Some("Color")); assert_eq!(t("Color::Red { x: 1 }").as_deref(), Some("Color")); assert_eq!(t("other_var"), None);
assert_eq!(t("MAX_SIZE"), None);
assert_eq!(t("config::MAX_SIZE"), None);
}
#[test]
fn self_returning_ctor_types_the_local_and_the_edge_survives() {
let src = r#"
pub struct Agent;
impl Agent {
pub fn new_with_defaults() -> Self { Agent }
pub fn run(&self) { let _ = std::fs::read("/tmp/x"); }
}
pub fn top() { let agent = Agent::new_with_defaults(); agent.run() }
"#;
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields: FieldIndex = HashMap::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let (mut ti, mut td, mut tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let (mut fe, mut ev) = (FieldElemIndex::new(), HashMap::new());
collect_decls(&file.items, false, &mut uses, &mut fields, &mut fe, &mut rets, &mut ev, &mut ti, &mut td, &mut tf, &mut std::collections::HashSet::new());
assert_eq!(rets.get("new_with_defaults"), Some(&Some("Agent".to_string())),
"Self must resolve to the impl type, not the literal");
}
#[test]
fn local_method_named_like_a_crate_does_not_inherit_the_crate_effect() {
let d = std::env::temp_dir().join(format!("candor-scan-localcrate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"localcrate\"\n").unwrap();
std::fs::write(
d.join("src/lib.rs"),
r#"
pub struct FastRand { one: u32 }
impl FastRand {
// a PURE local method merely NAMED like the `fastrand` crate (tokio's xorshift)
pub fn fastrand(&mut self) -> u32 { self.one ^= self.one << 1; self.one }
pub fn fastrand_n(&mut self, n: u32) -> u32 { self.fastrand() % n }
// a local method named like the `time`/`now` clock verb — also pure
pub fn time(&self) -> u32 { self.one }
}
pub fn uses_local(r: &mut FastRand) { let _ = r.fastrand_n(5); let _ = r.time(); }
// a REAL external dependency call — qualified, does NOT resolve locally → STILL Rand
pub fn uses_external() { let _ = fastrand::u32(0..10); }
"#,
)
.unwrap();
let idx = load_dep_reports(None);
let prefix = d.join("out/r").to_string_lossy().into_owned();
let (rc, body) = scan_one(&d.to_string_lossy(), ScanOpts {
prefix, want_json: true, include_tests: false, policy: None, quiet: true, deps_idx: &idx,
});
assert_eq!(rc, 0);
let body = body.expect("want_json returns the report body");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let effects_of = |needle: &str| -> Vec<String> {
v["functions"].as_array().into_iter().flatten()
.filter(|f| f["fn"].as_str().is_some_and(|q| q.contains(needle)))
.flat_map(|f| f["inferred"].as_array().into_iter().flatten()
.filter_map(|e| e.as_str().map(String::from)).collect::<Vec<_>>())
.collect()
};
for q in ["FastRand::fastrand", "FastRand::fastrand_n", "FastRand::time", "uses_local"] {
let eff = effects_of(q);
assert!(!eff.contains(&"Rand".to_string()) && !eff.contains(&"Clock".to_string()),
"local method named like a crate fabricated an effect on {q}: {eff:?}\n{body}");
}
assert!(effects_of("uses_external").contains(&"Rand".to_string()),
"a real external fastrand::u32 call must still report Rand:\n{body}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn local_fn_or_method_named_like_an_ffi_tier_does_not_fabricate() {
let d = std::env::temp_dir().join(format!("candor-scan-ffiname-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"ffiname\"\n").unwrap();
std::fs::write(
d.join("src/lib.rs"),
r#"
// PURE local free fns named like FFI tiers / whole-crate rules
pub fn sqlite3_step() -> i32 { 0 }
pub fn git_clone() {}
pub fn getrandom() -> u32 { 4 }
pub fn uses_sqlite() -> i32 { sqlite3_step() }
pub fn uses_git() { git_clone() }
pub fn uses_rand() -> u32 { getrandom() }
// a PURE local qualified Type::method named like the git_ tier
pub struct Repo;
impl Repo { pub fn git_remote_fetch(&self) {} }
pub fn uses_method(r: &Repo) { r.git_remote_fetch() }
// a GENUINE FFI binding (extern decl, no Rust body) — must STILL classify Db
extern "C" { fn sqlite3_exec(p: *mut i8) -> i32; }
pub fn real_ffi() { unsafe { sqlite3_exec(std::ptr::null_mut()); } }
"#,
)
.unwrap();
let idx = load_dep_reports(None);
let prefix = d.join("out/r").to_string_lossy().into_owned();
let (rc, body) = scan_one(&d.to_string_lossy(), ScanOpts {
prefix, want_json: true, include_tests: false, policy: None, quiet: true, deps_idx: &idx,
});
assert_eq!(rc, 0);
let body = body.expect("want_json returns the report body");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let effects_of = |needle: &str| -> Vec<String> {
v["functions"].as_array().into_iter().flatten()
.filter(|f| f["fn"].as_str().is_some_and(|q| q.contains(needle)))
.flat_map(|f| f["inferred"].as_array().into_iter().flatten()
.filter_map(|e| e.as_str().map(String::from)).collect::<Vec<_>>())
.collect()
};
for q in ["sqlite3_step", "git_clone", "getrandom", "uses_sqlite", "uses_git", "uses_rand",
"git_remote_fetch", "uses_method"] {
assert!(effects_of(q).is_empty(),
"local fn/method named like an FFI tier FABRICATED an effect on {q}: {:?}\n{body}",
effects_of(q));
}
assert!(effects_of("real_ffi").contains(&"Db".to_string()),
"a real extern-C sqlite3_exec FFI call must still report Db:\n{body}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn tuple_struct_fields_index_by_position() {
let src = r#"
pub struct Inner;
pub struct Outer(Inner);
pub struct Stack(Outer);
"#;
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields: FieldIndex = HashMap::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let (mut ti, mut td, mut tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let (mut fe, mut ev) = (FieldElemIndex::new(), HashMap::new());
collect_decls(&file.items, false, &mut uses, &mut fields, &mut fe, &mut rets, &mut ev, &mut ti, &mut td, &mut tf, &mut std::collections::HashSet::new());
assert_eq!(fields["Outer"]["0"], "Inner");
assert_eq!(fields["Stack"]["0"], "Outer");
}
fn typed_calls_of(src: &str) -> HashMap<String, Vec<String>> {
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields = FieldIndex::new();
let mut field_elem = FieldElemIndex::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let mut enum_tmp: HashMap<String, Option<String>> = HashMap::new();
let mut ti = TraitImplIndex::new();
let mut td: HashMap<String, LocalTrait> = HashMap::new();
let mut tf = TraitFieldIndex::new();
collect_decls(&file.items, false, &mut uses, &mut fields, &mut field_elem, &mut rets,
&mut enum_tmp, &mut ti, &mut td, &mut tf, &mut std::collections::HashSet::new());
let returns: ReturnIndex = rets.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let enum_variants: EnumVariantIndex =
enum_tmp.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let traits = TraitIndexes { impls: &ti, decls: &td, fields: &tf };
let elems = ElemIndexes { field_elem: &field_elem, enum_variants: &enum_variants };
let mut fns: Vec<FnInfo> = Vec::new();
let mut us2 = HashMap::new();
let mut locs = Vec::new();
fn_locs(&file.items, "lib.rs", false, &mut locs);
let mut loc_idx = 0usize;
scan_items(&file.items, "", &locs, &mut loc_idx, false, &fields, &returns, traits, elems, &mut us2, &mut fns);
fns.into_iter()
.map(|f| (f.qual, f.calls.into_iter().filter(|c| c.typed).map(|c| c.path).collect()))
.collect()
}
fn unresolved_of(src: &str) -> HashMap<String, bool> {
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields = FieldIndex::new();
let mut field_elem = FieldElemIndex::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let mut enum_tmp: HashMap<String, Option<String>> = HashMap::new();
let mut ti = TraitImplIndex::new();
let mut td: HashMap<String, LocalTrait> = HashMap::new();
let mut tf = TraitFieldIndex::new();
collect_decls(&file.items, false, &mut uses, &mut fields, &mut field_elem, &mut rets,
&mut enum_tmp, &mut ti, &mut td, &mut tf, &mut std::collections::HashSet::new());
let returns: ReturnIndex = rets.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let enum_variants: EnumVariantIndex =
enum_tmp.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let traits = TraitIndexes { impls: &ti, decls: &td, fields: &tf };
let elems = ElemIndexes { field_elem: &field_elem, enum_variants: &enum_variants };
let mut fns: Vec<FnInfo> = Vec::new();
let mut us2 = HashMap::new();
let mut locs = Vec::new();
fn_locs(&file.items, "lib.rs", false, &mut locs);
let mut loc_idx = 0usize;
scan_items(&file.items, "", &locs, &mut loc_idx, false, &fields, &returns, traits, elems, &mut us2, &mut fns);
fns.into_iter().map(|f| (f.qual, f.unresolved)).collect()
}
fn locs_of(src: &str) -> HashMap<String, String> {
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields = FieldIndex::new();
let mut field_elem = FieldElemIndex::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let mut enum_tmp: HashMap<String, Option<String>> = HashMap::new();
let mut ti = TraitImplIndex::new();
let mut td: HashMap<String, LocalTrait> = HashMap::new();
let mut tf = TraitFieldIndex::new();
collect_decls(&file.items, false, &mut uses, &mut fields, &mut field_elem, &mut rets,
&mut enum_tmp, &mut ti, &mut td, &mut tf, &mut std::collections::HashSet::new());
let returns: ReturnIndex = rets.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let enum_variants: EnumVariantIndex =
enum_tmp.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let traits = TraitIndexes { impls: &ti, decls: &td, fields: &tf };
let elems = ElemIndexes { field_elem: &field_elem, enum_variants: &enum_variants };
let mut fns: Vec<FnInfo> = Vec::new();
let mut us2 = HashMap::new();
let mut locs = Vec::new();
fn_locs(&file.items, "lib.rs", false, &mut locs);
let mut loc_idx = 0usize;
scan_items(&file.items, "", &locs, &mut loc_idx, false, &fields, &returns, traits, elems, &mut us2, &mut fns);
fns.into_iter().map(|f| (f.qual, f.loc)).collect()
}
#[test]
fn loc_carries_actual_line_and_col() {
let src = "\
fn alpha() {}
fn beta() {}
struct T;
impl T {
fn gamma(&self) {}
}
mod inner {
fn delta() {}
}
trait G {
fn hello(&self) {}
}
";
let m = locs_of(src);
assert_eq!(m["alpha"], "lib.rs:1:1");
assert_eq!(m["beta"], "lib.rs:2:5");
assert_eq!(m["T::gamma"], "lib.rs:5:5");
assert_eq!(m["inner::delta"], "lib.rs:8:5");
assert_eq!(m["G::hello"], "lib.rs:11:5");
}
#[test]
fn fn_typed_callback_invocation_is_unresolved() {
for hof in [
"fn h(cb: fn()) { cb(); }",
"fn h(cb: impl Fn()) { cb(); }",
"fn h<F: Fn()>(cb: F) { cb(); }",
"fn h(cb: &dyn Fn()) { cb(); }",
"fn h(cb: Box<dyn Fn()>) { cb(); }",
] {
let m = unresolved_of(hof);
assert!(m["h"], "fn-typed callback invocation silently dropped (not unresolved): {hof}");
}
for hof in [
"fn h(cb: impl Fn()) { let g = cb; g(); }",
"fn h(cb: fn()) { let g = if true { cb } else { return }; g(); }",
"fn s() {} fn h() { let g: fn() = s; g(); }",
] {
let m = unresolved_of(hof);
assert!(m["h"], "fn-typed callback rebound to a local silently dropped: {hof}");
}
let m = unresolved_of("fn helper() {} fn caller() { helper(); }");
assert!(!m["caller"], "a normal free-fn call must not be flagged unresolved");
let m = unresolved_of("struct T; impl T { fn m(&self) {} } fn f() { let x = T; let y = x; y.m(); }");
assert!(!m["f"], "a normal value rebind must not be flagged unresolved");
}
#[test]
fn cfg_test_items_excluded_from_default_report() {
let m = typed_calls_of(
"pub fn prod() {}\n\
#[cfg(test)] fn freefn() {}\n\
struct S;\n\
#[cfg(test)] impl S { fn blk(&self) {} }\n\
struct P; impl P { #[cfg(test)] fn meth(&self) {} fn keep(&self) {} }",
);
assert!(m.contains_key("prod"), "a production fn must be in the report");
assert!(m.keys().any(|k| k.ends_with("P::keep")), "a production method must be in the report");
for leaked in ["freefn", "S::blk", "P::meth"] {
assert!(!m.keys().any(|k| k.ends_with(leaked)), "a #[cfg(test)] item leaked: {leaked}");
}
}
#[test]
fn dropped_receiver_idioms_now_resolve() {
let prelude = "struct Sender; impl Sender { fn send(&self) {} }\n\
struct Pool { senders: Vec<Sender> }\n\
enum Conn { Active(Sender), Idle }\n";
let cases: &[(&str, &str)] = &[
("fn f(xs: Vec<Sender>) { for c in xs { c.send(); } }", "f"),
("fn f(xs: &[Sender]) { for c in xs { c.send(); } }", "f"),
("fn f(xs: Vec<Sender>) { xs.iter().for_each(|c| c.send()); }", "f"),
("fn f(xs: Vec<Sender>) { let _ = xs.iter().map(|c| c.send()).count(); }", "f"),
("fn f(xs: Vec<Sender>) { xs[0].send(); }", "f"),
("fn f(p: (Sender, usize)) { let (s, _) = p; s.send(); }", "f"),
];
for (body, fnname) in cases {
let src = format!("{prelude}{body}");
let m = typed_calls_of(&src);
let calls = m.get(*fnname).cloned().unwrap_or_default();
assert!(
calls.iter().any(|c| c == "Sender::send"),
"idiom dropped the effectful receiver (silent under-report): {body}\n typed calls: {calls:?}"
);
}
let m = typed_calls_of(&format!(
"{prelude}impl Pool {{ fn first(&self) {{ self.senders[0].send(); }} \
fn each(&self) {{ for c in &self.senders {{ c.send(); }} }} }}"
));
assert!(m["Pool::first"].iter().any(|c| c == "Sender::send"), "nested field+subscript dropped: {:?}", m["Pool::first"]);
assert!(m["Pool::each"].iter().any(|c| c == "Sender::send"), "for-loop over field dropped: {:?}", m["Pool::each"]);
let m = typed_calls_of(&format!(
"{prelude}fn g(c: Conn) {{ match c {{ Conn::Active(s) => s.send(), Conn::Idle => {{}} }} }}"
));
assert!(m["g"].iter().any(|c| c == "Sender::send"), "enum-payload match binding dropped: {:?}", m["g"]);
}
#[test]
fn idioms_never_fabricate_on_pure_elements() {
let prelude = "struct Pure; impl Pure { fn send(&self) {} }\n\
struct Bag { items: Vec<Pure> }\n";
let bodies: &[&str] = &[
"fn f(xs: Vec<Pure>) { for c in xs { c.send(); } }",
"fn f(xs: Vec<Pure>) { xs.iter().for_each(|c| c.send()); }",
"fn f(xs: Vec<Pure>) { xs[0].send(); }",
"fn f(xs: Vec<i32>) { for c in xs { let _ = c + 1; } }",
"fn f(p: (Pure, usize)) { let (s, _) = p; s.send(); }",
];
for body in bodies {
let m = typed_calls_of(&format!("{prelude}{body}"));
let calls = m.get("f").cloned().unwrap_or_default();
assert!(
calls.iter().all(|c| c != "Sender::send" && !c.contains("TcpStream")),
"fabricated an effectful edge on a pure element: {body}\n typed: {calls:?}"
);
}
}
#[test]
fn scoped_bindings_do_not_leak() {
let prelude = "struct Sender; impl Sender { fn send(&self) {} }\n\
struct Pure; impl Pure { fn send(&self) {} }\n\
fn mk() -> Pure { Pure }\n";
let m = typed_calls_of(&format!(
"{prelude}fn f(xs: Vec<Sender>, ys: Vec<Pure>) {{ for c in xs {{ c.send(); }} for c in ys {{ c.send(); }} }}"
));
let calls = &m["f"];
assert_eq!(
calls.iter().filter(|c| *c == "Sender::send").count(),
1,
"loop binding leaked into the next same-named loop (fabrication): {calls:?}"
);
let m = typed_calls_of(&format!(
"{prelude}fn f(xs: Vec<Sender>) {{ xs.iter().for_each(|c| c.send()); let c = mk(); c.send(); }}"
));
let calls = &m["f"];
assert!(
!calls.iter().any(|c| *c == "Sender::send" && calls.iter().filter(|x| *x == "Sender::send").count() > 1),
"closure param leaked"
);
assert_eq!(calls.iter().filter(|c| *c == "Sender::send").count(), 1,
"closure param binding leaked into a later same-named var: {calls:?}");
assert!(calls.iter().any(|c| c == "Pure::send"), "later c should type Pure::send: {calls:?}");
}
#[test]
fn elem_type_covers_the_collection_shapes() {
let u = uses(&[("Sender", "net::Sender")]);
let p = |s: &str| -> Option<String> {
let t: syn::Type = syn::parse_str(s).unwrap();
elem_type(&t, &u)
};
assert_eq!(p("Vec<Sender>").as_deref(), Some("net::Sender"));
assert_eq!(p("&[Sender]").as_deref(), Some("net::Sender"));
assert_eq!(p("[Sender; 4]").as_deref(), Some("net::Sender"));
assert_eq!(p("HashSet<Sender>").as_deref(), Some("net::Sender"));
assert_eq!(p("BTreeSet<Sender>").as_deref(), Some("net::Sender"));
assert_eq!(p("VecDeque<Sender>").as_deref(), Some("net::Sender"));
assert_eq!(p("Box<[Sender]>").as_deref(), Some("net::Sender"));
assert_eq!(p("Arc<Vec<Sender>>").as_deref(), Some("net::Sender"));
assert_eq!(p("Sender"), None);
assert_eq!(p("Option<Sender>"), None);
assert_eq!(p("HashMap<String, Sender>"), None);
}
#[test]
fn enum_variant_index_drops_ambiguous_leaves() {
let src = "enum A { One(i32), Pair(i32, i32), Unit }\n\
enum B { Two(String) }\n\
enum C { Two(Vec<u8>) }\n"; let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields = FieldIndex::new();
let mut field_elem = FieldElemIndex::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let mut enum_tmp: HashMap<String, Option<String>> = HashMap::new();
let (mut ti, mut td, mut tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
collect_decls(&file.items, false, &mut uses, &mut fields, &mut field_elem, &mut rets,
&mut enum_tmp, &mut ti, &mut td, &mut tf, &mut std::collections::HashSet::new());
let ev: EnumVariantIndex = enum_tmp.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
assert_eq!(ev.get("One").map(String::as_str), Some("i32")); assert_eq!(ev.get("Pair"), None); assert_eq!(ev.get("Unit"), None); assert_eq!(ev.get("Two"), None); }
#[test]
fn embedded_agents_contract_matches_the_repo_doc() {
let embedded = include_str!("../AGENTS.md");
assert!(embedded.contains("candor-scan"), "the contract must describe this tool");
match std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../AGENTS.md")) {
Ok(root) if root.contains("instructions for an AI coding agent") => {
assert_eq!(embedded, root, "crate AGENTS.md drifted from the repo root — re-copy it");
}
_ => { }
}
}
#[test]
fn toml_primitives_tolerate_spacing_and_comments() {
assert_eq!(toml_section("[ workspace ]"), Some("workspace"));
assert_eq!(toml_section("[package]"), Some("package"));
assert_eq!(toml_section("name = \"x\""), None);
assert_eq!(toml_scalar("name = \"my-crate\" # the name", "name"), Some("my-crate"));
assert_eq!(toml_scalar("name=bare # c", "name"), Some("bare"));
assert_eq!(toml_scalar("namespace = \"x\"", "name"), None); let d = std::env::temp_dir().join(format!("candor-scan-tomlhdr-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("Cargo.toml"), "[ package ]\nname = \"spaced-crate\" # trailing\n").unwrap();
assert_eq!(read_crate_name(&d).as_deref(), Some("spaced_crate"));
assert_eq!(
toml_string_array("[ workspace ]\nmembers = [\"a\", \"b\"]\n", "workspace", "members"),
vec!["a", "b"]
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn cargo_deps_excludes_nested_package_manifests() {
let d = std::env::temp_dir().join(format!("candor-scan-nesteddeps-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("fixture")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"outer\"\n[dependencies]\nserde = \"1\"\n").unwrap();
std::fs::write(d.join("fixture/Cargo.toml"), "[package]\nname = \"inner\"\n[dependencies]\nreqwest = \"0.12\"\n").unwrap();
let (deps, _) = cargo_deps(&d.to_string_lossy());
assert!(deps.contains("serde"), "the crate's own dep is present: {deps:?}");
assert!(!deps.contains("reqwest"), "a nested package's dep leaked into the parent: {deps:?}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn toml_string_array_reads_inline_and_multiline_members() {
let txt = "[package]\nname = \"x\"\n\n[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\nexclude = [\n \"eval\",\n \"sample\",\n]\n";
assert_eq!(toml_string_array(txt, "workspace", "members"), vec!["crates/a", "crates/b"]);
assert_eq!(toml_string_array(txt, "workspace", "exclude"), vec!["eval", "sample"]);
assert!(toml_string_array(txt, "workspace", "default-members").is_empty());
assert!(toml_string_array("[dependencies]\nserde = \"1\"\n", "workspace", "members").is_empty());
}
#[test]
fn workspace_members_expand_globs_and_honour_exclude() {
let d = std::env::temp_dir().join(format!("candor-scan-ws-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for m in ["crates/a", "crates/skipme", "tools/one", "crates/no-manifest"] {
std::fs::create_dir_all(d.join(m)).unwrap();
if m != "crates/no-manifest" {
std::fs::write(d.join(m).join("Cargo.toml"), "[package]\nname = \"m\"\n").unwrap();
}
}
std::fs::write(
d.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\", \"tools/one\", \"gone/away\"]\nexclude = [\"crates/skipme\"]\n",
)
.unwrap();
let got: Vec<String> = workspace_members(&d)
.into_iter()
.map(|p| p.strip_prefix(&format!("{}/", d.to_string_lossy())).unwrap().to_string())
.collect();
assert_eq!(got, vec!["crates/a", "tools/one"], "glob expands, exclude + missing-manifest drop");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn workspace_members_bare_star_and_dedup() {
let d = std::env::temp_dir().join(format!("candor-scan-ws2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for m in ["a", "b"] {
std::fs::create_dir_all(d.join(m)).unwrap();
std::fs::write(d.join(m).join("Cargo.toml"), "[package]\nname = \"m\"\n").unwrap();
}
std::fs::write(d.join("Cargo.toml"), "[workspace]\nmembers = [\"*\", \"a\"]\n").unwrap();
let got: Vec<String> = workspace_members(&d)
.into_iter()
.map(|p| p.strip_prefix(&format!("{}/", d.to_string_lossy())).unwrap().to_string())
.collect();
assert_eq!(got, vec!["a", "b"], "bare * expands to children, deduped against the explicit `a`");
assert!(has_workspace_table(&d));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn workspace_root_scans_members_even_under_deps_filter() {
let d = std::env::temp_dir().join(format!("candor-scan-wsfan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for (m, body) in [("a", "pub fn ea() { let _ = std::fs::read(\"/x\"); }"),
("b", "pub fn eb() { let _ = std::process::Command::new(\"x\"); }")] {
std::fs::create_dir_all(d.join(m).join("src")).unwrap();
std::fs::write(d.join(m).join("Cargo.toml"), format!("[package]\nname = \"{m}\"\n")).unwrap();
std::fs::write(d.join(m).join("src/lib.rs"), body).unwrap();
}
std::fs::write(d.join("Cargo.toml"), "[workspace]\nmembers = [\"a\", \"b\"]\n").unwrap();
let idx = load_dep_reports(None);
let prefix = d.join("out/r").to_string_lossy().into_owned();
let rc = scan_target(&d.to_string_lossy(), prefix.clone(), false, false, None, &idx);
assert_eq!(rc, 0);
let ra = std::fs::read_to_string(format!("{prefix}.a.scan.json")).unwrap();
let rb = std::fs::read_to_string(format!("{prefix}.b.scan.json")).unwrap();
assert!(ra.contains("ea") && ra.contains("Fs"), "member a not scanned: {ra}");
assert!(rb.contains("eb") && rb.contains("Exec"), "member b not scanned: {rb}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn nested_packages_are_not_modules_of_the_parent() {
let d = std::env::temp_dir().join(format!("candor-scan-nested-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::create_dir_all(d.join("fixture/src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"outer\"\n").unwrap();
std::fs::write(d.join("src/lib.rs"), "pub fn outer_eff() { let _ = std::fs::read(\"/x\"); }\n").unwrap();
std::fs::write(d.join("fixture/Cargo.toml"), "[package]\nname = \"inner\"\n").unwrap();
std::fs::write(
d.join("fixture/src/lib.rs"),
"pub fn inner_eff() { let _ = std::process::Command::new(\"x\"); }\n",
)
.unwrap();
let idx = load_dep_reports(None);
let prefix = d.join("out/r").to_string_lossy().into_owned();
let (rc, _) = scan_one(&d.to_string_lossy(), ScanOpts {
prefix: prefix.clone(), want_json: false, include_tests: false,
policy: None, quiet: true, deps_idx: &idx,
});
assert_eq!(rc, 0);
let rep = std::fs::read_to_string(format!("{prefix}.outer.scan.json")).unwrap();
assert!(rep.contains("outer_eff"), "the parent's own fn must report: {rep}");
assert!(!rep.contains("inner_eff") && !rep.contains("Exec"),
"nested package's fn leaked into the parent report: {rep}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn report_is_written_atomically_no_tmp_leftovers() {
let d = std::env::temp_dir().join(format!("candor-scan-atomic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"atomiccrate\"\n").unwrap();
std::fs::write(d.join("src/lib.rs"), "pub fn eff() { let _ = std::fs::read(\"/x\"); }\n").unwrap();
let idx = load_dep_reports(None);
let outdir = d.join("out");
let prefix = outdir.join("r").to_string_lossy().into_owned();
let (rc, _) = scan_one(&d.to_string_lossy(), ScanOpts {
prefix, want_json: false, include_tests: false, policy: None, quiet: true, deps_idx: &idx,
});
assert_eq!(rc, 0);
let leftovers: Vec<String> = std::fs::read_dir(&outdir).unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.filter(|n| n.contains(".tmp")).collect();
assert!(leftovers.is_empty(), "atomic write left temp files behind: {leftovers:?}");
for name in ["r.atomiccrate.scan.json", "r.atomiccrate.scan.callgraph.json"] {
let body = std::fs::read_to_string(outdir.join(name)).unwrap();
serde_json::from_str::<serde_json::Value>(&body)
.unwrap_or_else(|e| panic!("{name} is not whole JSON ({e}): {body}"));
}
let _ = std::fs::remove_dir_all(&d);
}
#[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"));
}
#[test]
fn resolve_target_is_precise_and_never_fabricates() {
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
for q in ["random::bool::bool", "clip::ClipboardThread::start", "util::helper",
"app::Worker::run", "a::Job::run", "b::Job::run"] {
by_leaf.entry(q.rsplit("::").next().unwrap().into()).or_default().push(q.into());
by_tail2.entry(tail2(q).unwrap()).or_default().push(q.into());
}
assert_eq!(resolve_target("Value::bool", "bool", false, &by_tail2, &by_leaf), None);
assert_eq!(resolve_target("start", "start", true, &by_tail2, &by_leaf), None);
assert_eq!(resolve_target("helper", "helper", false, &by_tail2, &by_leaf),
Some(&vec!["util::helper".to_string()]));
assert_eq!(resolve_target("Worker::run", "run", false, &by_tail2, &by_leaf),
Some(&vec!["app::Worker::run".to_string()]));
assert_eq!(resolve_target("Job::run", "run", false, &by_tail2, &by_leaf), None);
}
#[test]
fn every_merged_decls_field_is_folded_into_the_digest() {
let MergedDecls {
fields: _,
field_elem: _,
rets: _,
enum_tmp: _,
trait_impls: _,
trait_decls: _,
trait_fields: _,
prim_aliases: _,
} = MergedDecls::default();
let empty = decl_index_digest(&MergedDecls::default());
type Mutator = fn(&mut MergedDecls);
let mutators: Vec<(&str, Mutator)> = vec![
("fields", |m| { m.fields.entry("S".into()).or_default().insert("f".into(), "T".into()); }),
("field_elem", |m| { m.field_elem.entry("S".into()).or_default().insert("f".into(), "E".into()); }),
("rets", |m| { m.rets.insert("f".into(), Some("T".into())); }),
("enum_tmp", |m| { m.enum_tmp.insert("v".into(), Some("E".into())); }),
("trait_impls", |m| { m.trait_impls.entry("Tr".into()).or_default().push("Ty".into()); }),
("trait_decls", |m| { m.trait_decls.entry("Tr".into()).or_default().count += 1; }),
("trait_fields", |m| { m.trait_fields.entry("S".into()).or_default().insert("f".into(), vec!["b".into()]); }),
("prim_aliases", |m| { m.prim_aliases.insert("A".into()); }),
];
for (name, mutate) in mutators {
let mut m = MergedDecls::default();
mutate(&mut m);
assert_ne!(
decl_index_digest(&m), empty,
"MergedDecls.{name} changes the index but NOT the digest — the --incremental cache would \
reuse stale FnInfos. Fold `{name}` into decl_index_digest().",
);
}
}
}