use super::ids::crate_node_id;
use super::internal::{Edge, EdgeKind, GraphBuilder, Node, NodeId, NodeKind, Visibility};
use anyhow::{Context, Result};
use cargo_metadata::{Metadata, Package, PackageId, Target};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use syn::spanned::Spanned as _;
use syn::{Item, ItemMod, UseTree, Visibility as SynVis};
pub(crate) fn contribute(
metadata: &Metadata,
ignore_tests: bool,
builder: &mut GraphBuilder,
) -> Result<()> {
let local: HashSet<&PackageId> = metadata.workspace_members.iter().collect();
let mut works: Vec<TargetWork> = Vec::new();
let mut lib_index: HashMap<String, ForeignLib> = HashMap::new();
for pkg in &metadata.packages {
if !local.contains(&pkg.id) {
continue;
}
let (extern_crates, dep_pkg_by_name) = build_dep_maps(pkg, metadata);
let crate_id = crate_node_id(&pkg.id.repr);
let mut visited_files: HashSet<PathBuf> = HashSet::new();
for target in &pkg.targets {
if !is_supported_target(target) {
continue;
}
let root_mod_id =
module_node_id(&pkg.id.repr, target_kind_label(target), &target.name, &[]);
let root_label = format!("{} ({})", target.name, target_kind_label(target));
builder.add_node(Node {
id: root_mod_id.clone(),
kind: NodeKind::Module,
name: root_label,
path: target.src_path.to_string(),
parent: Some(crate_id.clone()),
external: None,
version: None,
visibility: Some(Visibility::Public),
loc: None,
line: None,
item_count: None,
unsafe_count: None,
crate_label: Some(crate_label(pkg, target)),
});
builder.add_edge(Edge {
from: crate_id.clone(),
to: root_mod_id.clone(),
kind: EdgeKind::Contains,
visibility: None,
line: None,
});
let mut module_index: HashMap<Vec<String>, NodeId> = HashMap::new();
module_index.insert(vec![], root_mod_id.clone());
let mut pending_uses: Vec<PendingUse> = Vec::new();
let src = target.src_path.clone().into_std_path_buf();
walk_file(
&src,
&root_mod_id,
&[],
pkg,
target,
ignore_tests,
&mut module_index,
&mut pending_uses,
builder,
&mut visited_files,
)
.with_context(|| format!("processing package {}", pkg.name))?;
if is_lib_target(target) {
lib_index.insert(
pkg.id.repr.clone(),
ForeignLib {
index: module_index.clone(),
reexports: build_reexports(&pending_uses),
},
);
}
works.push(TargetWork {
extern_crates: extern_crates.clone(),
dep_pkg_by_name: dep_pkg_by_name.clone(),
module_index,
pending_uses,
});
}
}
for w in &works {
emit_uses(
&w.pending_uses,
&w.module_index,
&w.extern_crates,
&w.dep_pkg_by_name,
&lib_index,
builder,
);
}
aggregate_crate_loc(builder);
Ok(())
}
struct TargetWork {
extern_crates: HashMap<String, NodeId>,
dep_pkg_by_name: HashMap<String, String>,
module_index: HashMap<Vec<String>, NodeId>,
pending_uses: Vec<PendingUse>,
}
fn aggregate_crate_loc(builder: &mut GraphBuilder) {
let entries: Vec<(String, u32)> = builder
.nodes_mut()
.iter()
.filter(|n| n.kind == NodeKind::Module)
.filter_map(|n| {
let loc = n.loc?;
let parent = n.parent.as_deref()?;
parent
.starts_with("crate:")
.then(|| (parent.to_string(), loc))
})
.collect();
let mut crate_loc: HashMap<String, u32> = HashMap::new();
for (crate_id, loc) in entries {
crate_loc
.entry(crate_id)
.and_modify(|v| *v += loc)
.or_insert(loc);
}
for node in builder.nodes_mut().iter_mut() {
if node.kind == NodeKind::Crate
&& let Some(total) = crate_loc.get(&node.id)
{
node.loc = Some(*total);
}
}
}
fn build_dep_maps(
pkg: &Package,
metadata: &Metadata,
) -> (HashMap<String, NodeId>, HashMap<String, String>) {
let mut extern_map = HashMap::new();
let mut pkg_map = HashMap::new();
let Some(resolve) = &metadata.resolve else {
return (extern_map, pkg_map);
};
let Some(node) = resolve.nodes.iter().find(|n| n.id == pkg.id) else {
return (extern_map, pkg_map);
};
for dep in &node.deps {
extern_map.insert(dep.name.clone(), crate_node_id(&dep.pkg.repr));
pkg_map.insert(dep.name.clone(), dep.pkg.repr.clone());
}
(extern_map, pkg_map)
}
fn crate_label(pkg: &Package, target: &Target) -> String {
let pkg_name = pkg.name.to_string();
if is_lib_target(target) {
pkg_name
} else if target.name == pkg_name {
format!("{pkg_name} (bin)")
} else {
format!("{pkg_name} (bin {})", target.name)
}
}
fn is_lib_target(target: &Target) -> bool {
target.kind.iter().any(|k| {
matches!(
k.as_str(),
"lib" | "rlib" | "dylib" | "cdylib" | "proc-macro"
)
})
}
#[derive(Debug)]
struct PendingUse {
from_mod_id: NodeId,
current_path: Vec<String>,
use_path: Vec<String>,
visibility: Visibility,
bare: bool,
glob: bool,
line: Option<u32>,
}
#[derive(Default)]
struct CratePathCollector {
paths: std::collections::BTreeSet<Vec<String>>,
}
impl<'ast> syn::visit::Visit<'ast> for CratePathCollector {
fn visit_path(&mut self, path: &'ast syn::Path) {
if path.segments.len() >= 2 {
self.paths
.insert(path.segments.iter().map(|s| s.ident.to_string()).collect());
}
syn::visit::visit_path(self, path);
}
fn visit_attribute(&mut self, attr: &'ast syn::Attribute) {
if attr.path().is_ident("derive")
&& let Ok(paths) = attr.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
{
for p in &paths {
if p.segments.len() >= 2 {
self.paths
.insert(p.segments.iter().map(|s| s.ident.to_string()).collect());
}
}
}
syn::visit::visit_attribute(self, attr);
}
}
#[derive(Default)]
struct UnsafeCounter {
count: u32,
}
impl<'ast> syn::visit::Visit<'ast> for UnsafeCounter {
fn visit_expr_unsafe(&mut self, node: &'ast syn::ExprUnsafe) {
self.count += 1;
syn::visit::visit_expr_unsafe(self, node);
}
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
if node.sig.unsafety.is_some() {
self.count += 1;
}
syn::visit::visit_item_fn(self, node);
}
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
if node.sig.unsafety.is_some() {
self.count += 1;
}
syn::visit::visit_impl_item_fn(self, node);
}
fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
if node.sig.unsafety.is_some() {
self.count += 1;
}
syn::visit::visit_trait_item_fn(self, node);
}
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
if node.unsafety.is_some() {
self.count += 1;
}
syn::visit::visit_item_impl(self, node);
}
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
if node.unsafety.is_some() {
self.count += 1;
}
syn::visit::visit_item_trait(self, node);
}
}
fn convert_visibility(v: &SynVis) -> Visibility {
match v {
SynVis::Public(_) => Visibility::Public,
SynVis::Restricted(r) => {
let s = r
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
match s.as_str() {
"crate" => Visibility::Crate,
"super" => Visibility::Super,
"self" | "" => Visibility::Private,
_ => Visibility::Restricted { path: s },
}
}
SynVis::Inherited => Visibility::Private,
}
}
fn is_reexport(v: &Visibility) -> bool {
!matches!(v, Visibility::Private)
}
#[allow(clippy::too_many_arguments)]
fn walk_file(
file_path: &Path,
parent_mod_id: &NodeId,
parent_mod_path: &[String],
pkg: &Package,
target: &Target,
ignore_tests: bool,
module_index: &mut HashMap<Vec<String>, NodeId>,
pending_uses: &mut Vec<PendingUse>,
builder: &mut GraphBuilder,
visited_files: &mut HashSet<PathBuf>,
) -> Result<()> {
if !visited_files.insert(file_path.to_path_buf()) {
return Ok(());
}
let content = std::fs::read_to_string(file_path)
.with_context(|| format!("reading {}", file_path.display()))?;
let parsed =
syn::parse_file(&content).with_context(|| format!("parsing {}", file_path.display()))?;
let loc = content.lines().count() as u32;
let item_count = count_items(&parsed.items) as u32;
let mut collector = CratePathCollector::default();
let mut unsafe_counter = UnsafeCounter::default();
for item in &parsed.items {
if ignore_tests && is_test_item(item) {
continue;
}
syn::visit::Visit::visit_item(&mut collector, item);
syn::visit::Visit::visit_item(&mut unsafe_counter, item);
}
if let Some(node) = builder
.nodes_mut()
.iter_mut()
.find(|n| n.id == *parent_mod_id)
{
node.loc = Some(loc);
node.item_count = Some(item_count);
node.unsafe_count = Some(unsafe_counter.count);
node.path = file_path.display().to_string();
}
for path in collector.paths {
pending_uses.push(PendingUse {
from_mod_id: parent_mod_id.clone(),
current_path: parent_mod_path.to_vec(),
use_path: path,
visibility: Visibility::Private,
bare: true,
glob: false,
line: None,
});
}
walk_items(
&parsed.items,
parent_mod_id,
parent_mod_path,
file_path,
pkg,
target,
ignore_tests,
module_index,
pending_uses,
builder,
visited_files,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_items(
items: &[Item],
current_mod_id: &NodeId,
current_mod_path: &[String],
enclosing_file: &Path,
pkg: &Package,
target: &Target,
ignore_tests: bool,
module_index: &mut HashMap<Vec<String>, NodeId>,
pending_uses: &mut Vec<PendingUse>,
builder: &mut GraphBuilder,
visited_files: &mut HashSet<PathBuf>,
) -> Result<()> {
for item in items {
if ignore_tests && is_test_item(item) {
continue;
}
match item {
Item::Mod(m) => {
process_mod(
m,
current_mod_id,
current_mod_path,
enclosing_file,
pkg,
target,
ignore_tests,
module_index,
pending_uses,
builder,
visited_files,
)?;
}
Item::Use(u) => {
let mut paths = Vec::new();
collect_use_paths(&u.tree, Vec::new(), &mut paths);
let vis = convert_visibility(&u.vis);
let line = Some(u.span().start().line as u32);
for (use_path, glob) in paths {
pending_uses.push(PendingUse {
from_mod_id: current_mod_id.clone(),
current_path: current_mod_path.to_vec(),
use_path,
visibility: vis.clone(),
bare: false,
glob,
line,
});
}
}
_ => {}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn process_mod(
m: &ItemMod,
parent_mod_id: &NodeId,
parent_mod_path: &[String],
enclosing_file: &Path,
pkg: &Package,
target: &Target,
ignore_tests: bool,
module_index: &mut HashMap<Vec<String>, NodeId>,
pending_uses: &mut Vec<PendingUse>,
builder: &mut GraphBuilder,
visited_files: &mut HashSet<PathBuf>,
) -> Result<()> {
let sub_name = m.ident.to_string();
let mut sub_path = parent_mod_path.to_vec();
sub_path.push(sub_name.clone());
let sub_mod_id = module_node_id(
&pkg.id.repr,
target_kind_label(target),
&target.name,
&sub_path,
);
let (loc, line) = if m.content.is_some() {
let span = m.span();
let start = span.start().line as u32;
let end = span.end().line as u32;
(Some(end - start + 1), Some(start))
} else {
(None, None)
};
builder.add_node(Node {
id: sub_mod_id.clone(),
kind: NodeKind::Module,
name: sub_name.clone(),
path: enclosing_file.display().to_string(),
parent: Some(parent_mod_id.clone()),
external: None,
version: None,
visibility: Some(convert_visibility(&m.vis)),
loc,
line,
item_count: None,
unsafe_count: None,
crate_label: Some(crate_label(pkg, target)),
});
builder.add_edge(Edge {
from: parent_mod_id.clone(),
to: sub_mod_id.clone(),
kind: EdgeKind::Contains,
visibility: None,
line: None,
});
module_index.insert(sub_path.clone(), sub_mod_id.clone());
if let Some((_, items)) = &m.content {
walk_items(
items,
&sub_mod_id,
&sub_path,
enclosing_file,
pkg,
target,
ignore_tests,
module_index,
pending_uses,
builder,
visited_files,
)?;
} else if let Some(sub_file) = mod_file_path(m, enclosing_file, &sub_name) {
walk_file(
&sub_file,
&sub_mod_id,
&sub_path,
pkg,
target,
ignore_tests,
module_index,
pending_uses,
builder,
visited_files,
)?;
}
Ok(())
}
fn collect_use_paths(tree: &UseTree, prefix: Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
match tree {
UseTree::Path(p) => {
let mut new_prefix = prefix;
new_prefix.push(p.ident.to_string());
collect_use_paths(&p.tree, new_prefix, out);
}
UseTree::Name(n) => {
let mut path = prefix;
path.push(n.ident.to_string());
out.push((path, false));
}
UseTree::Rename(r) => {
let mut path = prefix;
path.push(r.ident.to_string());
out.push((path, false));
}
UseTree::Glob(_) => {
if !prefix.is_empty() {
out.push((prefix, true));
}
}
UseTree::Group(g) => {
for sub in &g.items {
collect_use_paths(sub, prefix.clone(), out);
}
}
}
}
type ReexportMap = HashMap<Vec<String>, Vec<(String, Vec<String>)>>;
const MAX_REEXPORT_DEPTH: usize = 8;
#[derive(Default)]
struct ForeignLib {
index: HashMap<Vec<String>, NodeId>,
reexports: ReexportMap,
}
fn build_reexports(pending: &[PendingUse]) -> ReexportMap {
let mut map: ReexportMap = HashMap::new();
for pu in pending {
if pu.bare || !is_reexport(&pu.visibility) {
continue;
}
if let Some(sym) = pu.use_path.last() {
map.entry(pu.current_path.clone())
.or_default()
.push((sym.clone(), pu.use_path.clone()));
}
}
map
}
fn glob_target_module(use_path: &[String], current_path: &[String]) -> Option<Vec<String>> {
match use_path.first().map(String::as_str) {
Some("crate") => Some(use_path[1..].to_vec()),
Some("self") => {
let mut p = current_path.to_vec();
p.extend_from_slice(&use_path[1..]);
Some(p)
}
Some("super") => {
let mut p = current_path.to_vec();
let mut tail = use_path;
while tail.first().map(String::as_str) == Some("super") {
p.pop()?;
tail = &tail[1..];
}
p.extend_from_slice(tail);
Some(p)
}
Some(_) => {
let mut p = current_path.to_vec();
p.extend_from_slice(use_path);
Some(p)
}
None => None,
}
}
fn is_super_glob(pu: &PendingUse) -> bool {
if !pu.glob {
return false;
}
let Some(target) = glob_target_module(&pu.use_path, &pu.current_path) else {
return false;
};
target.len() < pu.current_path.len() && pu.current_path[..target.len()] == target[..]
}
fn emit_uses(
pending: &[PendingUse],
module_index: &HashMap<Vec<String>, NodeId>,
extern_crates: &HashMap<String, NodeId>,
dep_pkg_by_name: &HashMap<String, String>,
lib_index: &HashMap<String, ForeignLib>,
builder: &mut GraphBuilder,
) {
let reexports = build_reexports(pending);
let mut seen: HashSet<(NodeId, NodeId, String)> = HashSet::new();
for pu in pending {
let Some(target_id) = resolve_use_path(
&pu.use_path,
&pu.current_path,
module_index,
extern_crates,
dep_pkg_by_name,
lib_index,
&reexports,
0,
) else {
continue;
};
if target_id == pu.from_mod_id {
continue;
}
let kind = if !pu.bare && is_reexport(&pu.visibility) {
EdgeKind::Reexports
} else if is_super_glob(pu) {
EdgeKind::Super
} else {
EdgeKind::Uses
};
let kind_str = format!("{kind:?}");
if !seen.insert((pu.from_mod_id.clone(), target_id.clone(), kind_str)) {
continue;
}
builder.add_edge(Edge {
from: pu.from_mod_id.clone(),
to: target_id,
kind,
visibility: if matches!(kind, EdgeKind::Reexports) {
Some(pu.visibility.clone())
} else {
None
},
line: pu.line,
});
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_use_path(
use_path: &[String],
current_path: &[String],
module_index: &HashMap<Vec<String>, NodeId>,
extern_crates: &HashMap<String, NodeId>,
dep_pkg_by_name: &HashMap<String, String>,
lib_index: &HashMap<String, ForeignLib>,
reexports: &ReexportMap,
depth: usize,
) -> Option<NodeId> {
if use_path.is_empty() {
return None;
}
let first = use_path[0].as_str();
let rest = &use_path[1..];
match first {
"crate" => resolve_in_index(
&[],
rest,
module_index,
extern_crates,
dep_pkg_by_name,
lib_index,
reexports,
depth,
),
"self" => resolve_in_index(
current_path,
rest,
module_index,
extern_crates,
dep_pkg_by_name,
lib_index,
reexports,
depth,
),
"super" => {
let mut path = current_path.to_vec();
let mut tail = rest;
while tail.first().map(|s| s.as_str()) == Some("super") {
path.pop()?;
tail = &tail[1..];
}
path.pop()?;
resolve_in_index(
&path,
tail,
module_index,
extern_crates,
dep_pkg_by_name,
lib_index,
reexports,
depth,
)
}
"std" | "core" | "alloc" | "proc_macro" | "test" => None,
other => {
let mut probe = current_path.to_vec();
probe.push(first.to_string());
if module_index.contains_key(&probe) {
return resolve_in_index(
current_path,
use_path,
module_index,
extern_crates,
dep_pkg_by_name,
lib_index,
reexports,
depth,
);
}
if let Some(dep_repr) = dep_pkg_by_name.get(other)
&& let Some(foreign) = lib_index.get(dep_repr)
{
return walk_foreign(&[], rest, &foreign.index, &foreign.reexports, 0);
}
extern_crates.get(other).cloned()
}
}
}
fn walk_detailed(
base: &[String],
tail: &[String],
module_index: &HashMap<Vec<String>, NodeId>,
) -> Option<(NodeId, Vec<String>, usize)> {
let mut cur = base.to_vec();
let mut node = module_index.get(&cur)?.clone();
let mut consumed = 0usize;
for seg in tail {
let mut probe = cur.clone();
probe.push(seg.clone());
match module_index.get(&probe) {
Some(id) => {
node = id.clone();
cur = probe;
consumed += 1;
}
None => break,
}
}
Some((node, cur, consumed))
}
fn walk_foreign(
base: &[String],
tail: &[String],
index: &HashMap<Vec<String>, NodeId>,
reexports: &ReexportMap,
depth: usize,
) -> Option<NodeId> {
let (node, stop_path, consumed) = walk_detailed(base, tail, index)?;
if consumed >= tail.len() {
return Some(node);
}
if depth < MAX_REEXPORT_DEPTH
&& let Some(entries) = reexports.get(&stop_path)
{
let sym = &tail[consumed];
for (exported, source) in entries {
if exported == sym
&& let Some(redirected) =
resolve_foreign_source(source, &stop_path, index, reexports, depth + 1)
&& redirected != node
{
return Some(redirected);
}
}
}
Some(node)
}
fn resolve_foreign_source(
use_path: &[String],
current_path: &[String],
index: &HashMap<Vec<String>, NodeId>,
reexports: &ReexportMap,
depth: usize,
) -> Option<NodeId> {
if use_path.is_empty() {
return None;
}
let first = use_path[0].as_str();
let rest = &use_path[1..];
match first {
"crate" => walk_foreign(&[], rest, index, reexports, depth),
"self" => walk_foreign(current_path, rest, index, reexports, depth),
"super" => {
let mut path = current_path.to_vec();
let mut tail = rest;
while tail.first().map(|s| s.as_str()) == Some("super") {
path.pop()?;
tail = &tail[1..];
}
path.pop()?;
walk_foreign(&path, tail, index, reexports, depth)
}
"std" | "core" | "alloc" | "proc_macro" | "test" => None,
_ => {
let mut probe = current_path.to_vec();
probe.push(first.to_string());
if index.contains_key(&probe) {
walk_foreign(current_path, use_path, index, reexports, depth)
} else {
None
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_in_index(
base: &[String],
tail: &[String],
module_index: &HashMap<Vec<String>, NodeId>,
extern_crates: &HashMap<String, NodeId>,
dep_pkg_by_name: &HashMap<String, String>,
lib_index: &HashMap<String, ForeignLib>,
reexports: &ReexportMap,
depth: usize,
) -> Option<NodeId> {
let (node, stop_path, consumed) = walk_detailed(base, tail, module_index)?;
if consumed >= tail.len() {
return Some(node);
}
if depth < MAX_REEXPORT_DEPTH
&& let Some(entries) = reexports.get(&stop_path)
{
let sym = &tail[consumed];
for (exported, source) in entries {
if exported != sym {
continue;
}
if let Some(redirected) = resolve_use_path(
source,
&stop_path,
module_index,
extern_crates,
dep_pkg_by_name,
lib_index,
reexports,
depth + 1,
) && redirected != node
{
return Some(redirected);
}
}
}
Some(node)
}
fn mod_file_path(m: &ItemMod, enclosing_file: &Path, sub_name: &str) -> Option<PathBuf> {
if let Some(rel) = mod_path_attr(m) {
let base = enclosing_file.parent().unwrap_or_else(|| Path::new(""));
let candidate = base.join(&rel);
return candidate.exists().then_some(candidate);
}
resolve_submodule_path(enclosing_file, sub_name)
}
fn mod_path_attr(m: &ItemMod) -> Option<String> {
for attr in &m.attrs {
if attr.path().is_ident("path")
&& let syn::Meta::NameValue(nv) = &attr.meta
&& let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
{
return Some(s.value());
}
}
None
}
fn resolve_submodule_path(parent_file: &Path, mod_name: &str) -> Option<PathBuf> {
let parent_dir = parent_file.parent()?;
let parent_stem = parent_file.file_stem()?.to_str()?;
let search_dir = if matches!(parent_stem, "lib" | "main" | "mod") {
parent_dir.to_path_buf()
} else {
parent_dir.join(parent_stem)
};
let candidate_a = search_dir.join(format!("{mod_name}.rs"));
if candidate_a.exists() {
return Some(candidate_a);
}
let candidate_b = search_dir.join(mod_name).join("mod.rs");
if candidate_b.exists() {
return Some(candidate_b);
}
None
}
fn is_supported_target(target: &Target) -> bool {
target.kind.iter().any(|k| {
matches!(
k.as_str(),
"lib" | "rlib" | "dylib" | "cdylib" | "proc-macro" | "bin"
)
})
}
fn target_kind_label(target: &Target) -> &str {
target
.kind
.iter()
.map(String::as_str)
.find(|k| {
matches!(
*k,
"lib" | "rlib" | "dylib" | "cdylib" | "proc-macro" | "bin"
)
})
.unwrap_or("?")
}
fn target_ns(pkg_id_repr: &str, target_kind: &str, target_name: &str) -> String {
format!("mod:{pkg_id_repr}::{target_kind}:{target_name}")
}
fn module_node_id(
pkg_id_repr: &str,
target_kind: &str,
target_name: &str,
path: &[String],
) -> String {
let ns = target_ns(pkg_id_repr, target_kind, target_name);
if path.is_empty() {
ns
} else {
format!("{ns}::{}", path.join("::"))
}
}
fn is_test_item(item: &Item) -> bool {
let attrs: &[syn::Attribute] = match item {
Item::Mod(i) => &i.attrs,
Item::Fn(i) => &i.attrs,
Item::Impl(i) => &i.attrs,
Item::Struct(i) => &i.attrs,
Item::Enum(i) => &i.attrs,
Item::Trait(i) => &i.attrs,
Item::Type(i) => &i.attrs,
Item::Const(i) => &i.attrs,
Item::Static(i) => &i.attrs,
Item::Use(i) => &i.attrs,
Item::Macro(i) => &i.attrs,
Item::Union(i) => &i.attrs,
_ => return false,
};
attrs.iter().any(is_test_attr)
}
fn is_test_attr(attr: &syn::Attribute) -> bool {
if attr.path().is_ident("test") || attr.path().is_ident("bench") {
return true;
}
if attr.path().is_ident("cfg")
&& let Ok(list) = attr.meta.require_list()
{
return tokens_have_test_ident(list.tokens.clone());
}
false
}
fn tokens_have_test_ident(ts: proc_macro2::TokenStream) -> bool {
ts.into_iter().any(|tt| match tt {
proc_macro2::TokenTree::Ident(i) => i == "test",
proc_macro2::TokenTree::Group(g) => tokens_have_test_ident(g.stream()),
_ => false,
})
}
fn count_items(items: &[Item]) -> usize {
items
.iter()
.filter(|i| {
matches!(
i,
Item::Fn(_)
| Item::Struct(_)
| Item::Enum(_)
| Item::Trait(_)
| Item::Impl(_)
| Item::Type(_)
| Item::Const(_)
| Item::Static(_)
| Item::Mod(_)
| Item::Macro(_)
| Item::Union(_)
)
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn super_glob_only_marks_ancestor_namespace_pulls() {
let pu = |use_path: &[&str], current: &[&str], glob: bool| PendingUse {
from_mod_id: "x".into(),
current_path: current.iter().map(|s| s.to_string()).collect(),
use_path: use_path.iter().map(|s| s.to_string()).collect(),
visibility: Visibility::Private,
bare: false,
glob,
line: None,
};
assert!(is_super_glob(&pu(&["super"], &["assets", "lazy"], true)));
assert!(is_super_glob(&pu(
&["crate", "assets"],
&["assets", "lazy"],
true
)));
assert!(!is_super_glob(&pu(&["serialized"], &["assets"], true)));
assert!(!is_super_glob(&pu(
&["crate", "syntax_mapping"],
&["syntax_mapping", "builtin"],
false
)));
assert!(!is_super_glob(&pu(
&["rayon", "prelude"],
&["assets"],
true
)));
}
fn count_unsafe(src: &str) -> u32 {
let f = syn::parse_file(src).unwrap();
let mut counter = UnsafeCounter::default();
for item in &f.items {
if is_test_item(item) {
continue;
}
syn::visit::Visit::visit_item(&mut counter, item);
}
counter.count
}
#[test]
fn counts_unsafe_blocks_fns_impls_and_traits() {
let src = r#"
fn uses_block() {
unsafe { core::ptr::null::<u8>(); }
}
unsafe fn raw() {}
unsafe trait Marker {}
unsafe impl Marker for u8 {}
"#;
assert_eq!(count_unsafe(src), 4);
}
#[test]
fn unsafe_in_production_is_counted_but_tests_are_excluded() {
let src = r#"
fn prod() {
unsafe { core::ptr::null::<u8>(); }
}
#[cfg(test)]
mod tests {
#[test]
fn t() {
unsafe { core::ptr::null::<u8>(); }
unsafe { core::ptr::null::<u8>(); }
}
}
"#;
assert_eq!(count_unsafe(src), 1);
}
#[test]
fn no_unsafe_is_zero() {
assert_eq!(count_unsafe("fn safe() { let _ = 1 + 1; }"), 0);
}
#[test]
fn unsafe_ignores_keyword_lookalikes() {
let src = r#"
// unsafe unsafe — just a comment mentioning unsafe
/// doc comment: this fn is not unsafe
fn super_unsafe_fn() -> &'static str {
let unsafe_mode = "unsafe { } unsafe fn impl trait";
unsafe_mode
}
struct UnsafeWrapper;
enum UnsafeKind { A, B }
"#;
assert_eq!(
count_unsafe(src),
0,
"`unsafe` in names/comments/strings must not be counted"
);
}
fn use_paths(src: &str) -> Vec<Vec<String>> {
let f = syn::parse_file(src).unwrap();
let mut out = Vec::new();
for item in &f.items {
if let Item::Use(u) = item {
collect_use_paths(&u.tree, Vec::new(), &mut out);
}
}
out.into_iter().map(|(p, _)| p).collect()
}
#[test]
fn flattens_simple_use() {
let paths = use_paths("use foo::bar::Baz;");
assert_eq!(paths, vec![vec!["foo", "bar", "Baz"]]);
}
#[test]
fn flattens_group() {
let paths = use_paths("use foo::{bar, baz::Qux};");
assert_eq!(paths, vec![vec!["foo", "bar"], vec!["foo", "baz", "Qux"],]);
}
#[test]
fn flattens_glob() {
let paths = use_paths("use foo::bar::*;");
assert_eq!(paths, vec![vec!["foo", "bar"]]);
}
#[test]
fn resolve_use_path_simple_cases() {
let s = |x: &str| x.to_string();
struct Case {
label: &'static str,
path: Vec<String>,
current: Vec<String>,
index: Vec<(Vec<String>, &'static str)>,
externs: Vec<(&'static str, &'static str)>,
want: Option<&'static str>,
}
let cases = vec![
Case {
label: "crate::a::b → AB",
path: vec![s("crate"), s("a"), s("b")],
current: vec![],
index: vec![
(vec![], "ROOT"),
(vec![s("a")], "A"),
(vec![s("a"), s("b")], "AB"),
],
externs: vec![],
want: Some("AB"),
},
Case {
label: "super::super::x → root sibling X",
path: vec![s("super"), s("super"), s("x")],
current: vec![s("a"), s("b")],
index: vec![
(vec![], "ROOT"),
(vec![s("a")], "A"),
(vec![s("a"), s("b")], "AB"),
(vec![s("x")], "X"),
],
externs: vec![],
want: Some("X"),
},
Case {
label: "extern crate serde::Deserialize",
path: vec![s("serde"), s("Deserialize")],
current: vec![],
index: vec![],
externs: vec![("serde", "crate:serde")],
want: Some("crate:serde"),
},
Case {
label: "std is suppressed",
path: vec![s("std"), s("collections")],
current: vec![],
index: vec![],
externs: vec![],
want: None,
},
];
let mut fails = Vec::new();
for c in &cases {
let idx: HashMap<Vec<String>, NodeId> = c
.index
.iter()
.cloned()
.map(|(k, v)| (k, v.into()))
.collect();
let externs: HashMap<String, NodeId> = c
.externs
.iter()
.map(|(k, v)| (k.to_string(), (*v).into()))
.collect();
let got = resolve_use_path(
&c.path,
&c.current,
&idx,
&externs,
&HashMap::new(),
&HashMap::new(),
&ReexportMap::new(),
0,
);
if got.as_deref() != c.want {
fails.push(format!(
"{}: want {:?}, got {:?}",
c.label,
c.want,
got.as_deref()
));
}
}
assert!(
fails.is_empty(),
"resolve_use_path cases failed:\n{}",
fails.join("\n")
);
}
#[test]
fn same_named_lib_and_bin_get_distinct_ids() {
assert_ne!(
module_node_id("bat 1.0", "lib", "bat", &[]),
module_node_id("bat 1.0", "bin", "bat", &[]),
);
assert_ne!(
module_node_id("bat 1.0", "lib", "bat", &["theme".into()]),
module_node_id("bat 1.0", "bin", "bat", &["theme".into()]),
);
}
#[test]
fn follows_reexport_to_definer() {
let mut idx: HashMap<Vec<String>, NodeId> = HashMap::new();
idx.insert(vec![], "ROOT".into());
idx.insert(vec!["domain".into()], "DOMAIN".into());
idx.insert(vec!["domain".into(), "error".into()], "ERROR".into());
idx.insert(vec!["domain".into(), "local_client".into()], "LC".into());
let mut rx = ReexportMap::new();
rx.insert(
vec!["domain".into()],
vec![(
"DomainError".into(),
vec!["error".into(), "DomainError".into()],
)],
);
let r = resolve_use_path(
&["super".into(), "DomainError".into()],
&["domain".into(), "local_client".into()],
&idx,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&rx,
0,
);
assert_eq!(r.as_deref(), Some("ERROR"));
let r0 = resolve_use_path(
&["super".into(), "DomainError".into()],
&["domain".into(), "local_client".into()],
&idx,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&ReexportMap::new(),
0,
);
assert_eq!(r0.as_deref(), Some("DOMAIN"));
}
#[test]
fn resolve_use_path_handles_intra_crate_bare_path() {
let mut index: HashMap<Vec<String>, NodeId> = HashMap::new();
index.insert(vec![], "mod:crate".into());
index.insert(vec!["commands".into()], "mod:commands".into());
let externs: HashMap<String, NodeId> = HashMap::new();
let no_deps: HashMap<String, String> = HashMap::new();
let no_libs: HashMap<String, ForeignLib> = HashMap::new();
assert_eq!(
resolve_use_path(
&["commands".into(), "run".into()],
&[],
&index,
&externs,
&no_deps,
&no_libs,
&ReexportMap::new(),
0,
)
.as_deref(),
Some("mod:commands")
);
let mut externs2: HashMap<String, NodeId> = HashMap::new();
externs2.insert("once_cell".into(), "crate:once_cell".into());
assert_eq!(
resolve_use_path(
&["once_cell".into(), "sync".into()],
&[],
&index,
&externs2,
&no_deps,
&no_libs,
&ReexportMap::new(),
0,
)
.as_deref(),
Some("crate:once_cell")
);
}
#[test]
fn resolves_cross_crate_use_to_submodule_file() {
let mut foreign: HashMap<Vec<String>, NodeId> = HashMap::new();
foreign.insert(vec![], "mod:api::lib".into());
foreign.insert(vec!["node".into()], "mod:api::lib::node".into());
let mut lib_index: HashMap<String, ForeignLib> = HashMap::new();
lib_index.insert(
"api 1.0".into(),
ForeignLib {
index: foreign,
reexports: ReexportMap::new(),
},
);
let mut dep_pkg_by_name: HashMap<String, String> = HashMap::new();
dep_pkg_by_name.insert("api".into(), "api 1.0".into());
let mut externs: HashMap<String, NodeId> = HashMap::new();
externs.insert("api".into(), "crate:api".into());
assert_eq!(
resolve_use_path(
&["api".into(), "node".into(), "Node".into()],
&[],
&HashMap::new(),
&externs,
&dep_pkg_by_name,
&lib_index,
&ReexportMap::new(),
0,
)
.as_deref(),
Some("mod:api::lib::node")
);
assert_eq!(
resolve_use_path(
&["api".into(), "TopItem".into()],
&[],
&HashMap::new(),
&externs,
&dep_pkg_by_name,
&lib_index,
&ReexportMap::new(),
0,
)
.as_deref(),
Some("mod:api::lib")
);
}
#[test]
fn resolves_cross_crate_reexport_to_definer() {
let mut foreign: HashMap<Vec<String>, NodeId> = HashMap::new();
foreign.insert(vec![], "mod:sec::lib".into());
foreign.insert(
vec!["access_scope".into()],
"mod:sec::lib::access_scope".into(),
);
let mut rx = ReexportMap::new();
rx.insert(
vec![],
vec![(
"AccessScope".into(),
vec!["access_scope".into(), "AccessScope".into()],
)],
);
let mut lib_index: HashMap<String, ForeignLib> = HashMap::new();
lib_index.insert(
"sec 1.0".into(),
ForeignLib {
index: foreign,
reexports: rx,
},
);
let mut dep_pkg_by_name: HashMap<String, String> = HashMap::new();
dep_pkg_by_name.insert("sec".into(), "sec 1.0".into());
let mut externs: HashMap<String, NodeId> = HashMap::new();
externs.insert("sec".into(), "crate:sec".into());
assert_eq!(
resolve_use_path(
&["sec".into(), "AccessScope".into()],
&[],
&HashMap::new(),
&externs,
&dep_pkg_by_name,
&lib_index,
&ReexportMap::new(),
0,
)
.as_deref(),
Some("mod:sec::lib::access_scope")
);
assert_eq!(
resolve_use_path(
&["sec".into(), "NotReexported".into()],
&[],
&HashMap::new(),
&externs,
&dep_pkg_by_name,
&lib_index,
&ReexportMap::new(),
0,
)
.as_deref(),
Some("mod:sec::lib")
);
}
#[test]
fn collector_captures_qualified_paths() {
let f = syn::parse_file(
"fn run() { let _ = once_cell::sync::Lazy::new(|| 1); commands::go(); plain(); }",
)
.unwrap();
let mut c = CratePathCollector::default();
syn::visit::Visit::visit_file(&mut c, &f);
assert!(
c.paths.contains(&vec![
"once_cell".into(),
"sync".into(),
"Lazy".into(),
"new".into()
]),
"got {:?}",
c.paths
);
assert!(
c.paths.contains(&vec!["commands".into(), "go".into()]),
"got {:?}",
c.paths
);
assert!(
!c.paths.iter().any(|p| p == &vec!["plain".to_string()]),
"single-segment call ignored"
);
}
#[test]
fn collector_ignores_paths_in_strings_and_comments() {
let f = syn::parse_file(
"// commands::go() once_cell::sync::Lazy\n\
fn run() { let _ = \"once_cell::sync::Lazy and commands::go\"; }",
)
.unwrap();
let mut c = CratePathCollector::default();
syn::visit::Visit::visit_file(&mut c, &f);
assert!(
c.paths.is_empty(),
"no path should come from comment/string text, got {:?}",
c.paths
);
}
#[test]
fn collector_scales_with_real_paths_not_text() {
for reals in 0..6 {
for noise in 0..4 {
let mut body = String::new();
for i in 0..noise {
body.push_str(&format!("let _c{i} = \"mod{i}::go() once::Lazy\"; "));
}
for i in 0..reals {
body.push_str(&format!("mod{i}::go(); "));
}
let src = format!("fn run() {{ {body} }}");
let f = syn::parse_file(&src).unwrap();
let mut c = CratePathCollector::default();
syn::visit::Visit::visit_file(&mut c, &f);
let got = c
.paths
.iter()
.filter(|p| p.len() == 2 && p[0].starts_with("mod") && p[1] == "go")
.count();
assert_eq!(
got, reals,
"expected exactly {reals} real paths (noise={noise}), got {:?}",
c.paths
);
}
}
}
#[test]
fn collector_captures_qualified_derive_paths() {
let f = syn::parse_file("#[derive(Debug, serde::Serialize, thiserror::Error)] struct S;")
.unwrap();
let mut c = CratePathCollector::default();
syn::visit::Visit::visit_file(&mut c, &f);
assert!(
c.paths.contains(&vec!["serde".into(), "Serialize".into()]),
"got {:?}",
c.paths
);
assert!(
c.paths.contains(&vec!["thiserror".into(), "Error".into()]),
"got {:?}",
c.paths
);
assert!(
!c.paths.iter().any(|p| p == &vec!["Debug".to_string()]),
"single-segment derive ignored"
);
}
}