#![deny(missing_docs)]
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use syn::parse::Parser;
use syn::visit::Visit;
mod resolve;
use resolve::{
AliasMap, BareFallback, DynCollector, ExternRenameMap, ImplTraitCollector, PathCollector,
ReexportMap, ShapeExposure, UseMap, alias_nominal_target, bare_local_alias, canonical_path_str,
canonical_self_owner, canonicalize_through_aliases, canonicalize_through_reexports,
collect_reexports, collect_uses, extern_verbatim_renamed, path_to_string, resolve_path,
stamp_seam, strip_raw, type_to_string,
};
pub use xuanji::{
Baseline, BoundaryKind, Outcome, Report, Severity, Violation, ViolationId, apply_baseline,
};
pub const SIGNATURE_RULE: &str = "must not expose";
pub const DYN_TRAIT_RULE: &str = "must not expose dyn";
pub const IMPL_TRAIT_RULE: &str = "must not expose impl trait";
pub const ASYNC_EXPOSURE_RULE: &str = "must not expose async fn";
pub const TRAIT_IMPL_RULE: &str = "must only be implemented in the declared location(s)";
pub const VISIBILITY_RULE: &str = "must not declare pub items";
pub const FORBIDDEN_MARKER_RULE: &str = "must not acquire trait";
mod dsl;
pub use dsl::*;
fn unreadable_workspace_error(manifest_path: &Path, err: &str) -> String {
format!(
"a boundary is observed against a real workspace, so an unreadable one cannot be judged \
and its verdict would be a false pass: cannot read target workspace at {} ({err}); check \
the manifest path and that `cargo metadata` succeeds",
manifest_path.display()
)
}
fn crate_not_found_error(crate_package: &str) -> String {
format!(
"a boundary must govern a real crate or it silently never reacts: target crate \
'{crate_package}' is not a member of the target workspace — check the name or --manifest-path"
)
}
fn missing_src_error(crate_package: &str) -> String {
format!(
"a semantic boundary is observed from source, so with no src it could never react: cannot \
locate the crate root source for '{crate_package}'"
)
}
fn unknown_module_error(module: &str, crate_package: &str) -> String {
format!(
"a boundary must anchor to a real module or it silently never reacts: module '{module}' is \
not found among the modules of crate '{crate_package}' (declared via `mod`) — check the path"
)
}
fn unknown_trait_error(trait_path: &str, crate_package: &str) -> String {
format!(
"a trait-impl-locality boundary must anchor to a real local trait or it silently never \
reacts: trait '{trait_path}' is not found as a `trait` item (directly or via a local \
`pub use`) in crate '{crate_package}' — check the path"
)
}
fn missing_module_file_error(module: &str, crate_package: &str) -> String {
format!(
"module '{module}' of crate '{crate_package}' is declared (`mod …;`) but its source file \
could not be located (expected `<name>.rs` or `<name>/mod.rs`)"
)
}
fn unreadable_source_error(file: &Path, err: &str) -> String {
format!("cannot read source file '{}': {err}", file.display())
}
fn unparseable_source_error(file: &Path, err: &str) -> String {
format!("cannot parse source file '{}': {err}", file.display())
}
#[derive(Debug, Clone, Default)]
pub struct SemanticBoundaries {
pub signature: Vec<SemanticBoundary>,
pub trait_impl: Vec<TraitImplBoundary>,
pub visibility: Vec<VisibilityBoundary>,
pub forbidden_marker: Vec<ForbiddenMarkerBoundary>,
pub dyn_trait: Vec<DynTraitBoundary>,
pub impl_trait: Vec<ImplTraitBoundary>,
pub async_exposure: Vec<AsyncExposureBoundary>,
}
impl SemanticBoundaries {
pub fn is_empty(&self) -> bool {
self.signature.is_empty()
&& self.trait_impl.is_empty()
&& self.visibility.is_empty()
&& self.forbidden_marker.is_empty()
&& self.dyn_trait.is_empty()
&& self.impl_trait.is_empty()
&& self.async_exposure.is_empty()
}
}
pub fn check_all(boundaries: &SemanticBoundaries, manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in &boundaries.signature {
if let Err(error) = check_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.trait_impl {
if let Err(error) = check_trait_impl_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.visibility {
if let Err(error) = check_visibility_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.forbidden_marker {
if let Err(error) = check_forbidden_marker_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.dyn_trait {
if let Err(error) = check_dyn_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.impl_trait {
if let Err(error) = check_impl_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.async_exposure {
if let Err(error) = check_async_exposure_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
pub fn check(boundaries: &[SemanticBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn seam_file(
findings: &[String],
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Option<String>, String> {
if findings.is_empty() {
return Ok(None);
}
Ok(Some(
resolve_module_file(src_dir, root_file, module, crate_package)?
.display()
.to_string(),
))
}
fn per_finding_file(
module: &str,
src_dir: &Path,
root_file: &Path,
crate_package: &str,
cache: &mut HashMap<String, Option<String>>,
) -> Option<String> {
if let Some(cached) = cache.get(module) {
return cached.clone();
}
let file = resolve_module_file(src_dir, root_file, module, crate_package)
.ok()
.map(|path| path.display().to_string());
cache.insert(module.to_string(), file.clone());
file
}
fn resolve_crate<'m>(
metadata: &'m Value,
crate_package: &str,
) -> Result<(&'m Value, PathBuf, PathBuf), String> {
let package = find_package(metadata, crate_package)
.ok_or_else(|| crate_not_found_error(crate_package))?;
let root_file = crate_root_file(package).ok_or_else(|| missing_src_error(crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(crate_package))?
.to_path_buf();
Ok((package, root_file, src_dir))
}
fn check_boundary(
metadata: &Value,
boundary: &SemanticBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden,
&boundary.crate_package,
boundary.including_trait_impls,
&dependency_names(package),
)?;
let module_file = seam_file(
&findings,
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
SIGNATURE_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(module_file.clone()),
);
}
Ok(())
}
const SYSROOT_CRATES: [&str; 5] = ["std", "core", "alloc", "proc_macro", "test"];
fn dependency_names(package: &Value) -> Vec<String> {
package["dependencies"]
.as_array()
.map(|deps| {
deps.iter()
.filter_map(|dep| {
dep["rename"]
.as_str()
.or_else(|| dep["name"].as_str())
.map(|name| name.replace('-', "_"))
})
.collect()
})
.unwrap_or_default()
}
fn external_crate_set(dep_names: &[String]) -> HashSet<String> {
dep_names
.iter()
.cloned()
.chain(SYSROOT_CRATES.iter().map(|s| s.to_string()))
.collect()
}
fn local_type_namespace_names(items: &[syn::Item]) -> HashSet<String> {
items
.iter()
.filter_map(|item| match item {
syn::Item::Mod(m) => Some(strip_raw(&m.ident.to_string())),
syn::Item::Struct(s) => Some(strip_raw(&s.ident.to_string())),
syn::Item::Enum(e) => Some(strip_raw(&e.ident.to_string())),
syn::Item::Union(u) => Some(strip_raw(&u.ident.to_string())),
syn::Item::Trait(t) => Some(strip_raw(&t.ident.to_string())),
syn::Item::Type(t) => Some(strip_raw(&t.ident.to_string())),
_ => None,
})
.collect()
}
pub(crate) fn module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
include_trait_impls: bool,
dep_names: &[String],
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let externs = external_crate_set(dep_names);
let externs_type: HashSet<String> = externs
.difference(&local_type_namespace_names(&items))
.cloned()
.collect();
let scan = scan_crate(src_dir, root_file, crate_package, &externs)?;
let reexports = scan.reexports;
let aliases = scan.aliases;
let extern_renames = scan.extern_renames;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposed = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_exposures(item, module, &uses, ordinal, &mut exposed);
if include_trait_impls {
collect_trait_impl_exposures(item, module, &uses, ordinal, &mut exposed);
}
}
let mut findings: Vec<String> = exposed
.iter()
.filter_map(|exposure| {
let type_externs = if exposure.is_reexport {
&externs
} else {
&externs_type
};
let resolved = if exposure.path.leading_colon.is_some() {
extern_verbatim_renamed(&exposure.path, &externs, &extern_renames)
} else {
resolve_path(&exposure.path, &uses, module, BareFallback::Ignore)
.or_else(|| bare_local_alias(&exposure.path, module, &aliases))
.or_else(|| {
extern_verbatim_renamed(&exposure.path, type_externs, &extern_renames)
})
};
resolved
.map(|canonical| canonicalize_through_aliases(&canonical, &aliases, &reexports))
.filter(|canonical| matches_forbidden(canonical, &forbidden))
.map(|canonical| {
SemanticFinding::Exposed {
subject: canonical,
seam: exposure.seam.clone(),
}
.to_string()
})
})
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_dyn_trait(boundaries: &[DynTraitBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_dyn_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_dyn_trait_boundary(
metadata: &Value,
boundary: &DynTraitBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = if boundary.forbidden_operands.is_empty() {
dyn_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?
} else {
dyn_operand_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden_operands,
&boundary.crate_package,
&dependency_names(package),
)?
};
let module_file = seam_file(
&findings,
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
DYN_TRAIT_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(module_file.clone()),
);
}
Ok(())
}
pub(crate) fn dyn_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_dyn_exposures(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures.into_iter().map(shape_finding).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub(crate) fn dyn_operand_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
dep_names: &[String],
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let ExternResolution {
externs,
externs_type,
reexports,
extern_renames,
} = extern_resolution(src_dir, root_file, crate_package, dep_names, &items)?;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_dyn_exposures(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures
.into_iter()
.filter(|exposure| {
forbidden.is_empty()
|| exposure
.principal
.as_ref()
.and_then(|path| {
resolve_principal(
path,
&uses,
module,
&externs,
&externs_type,
&extern_renames,
&reexports,
)
})
.is_some_and(|canonical| matches_forbidden(&canonical, &forbidden))
})
.map(shape_finding)
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
struct ExternResolution {
externs: HashSet<String>,
externs_type: HashSet<String>,
reexports: ReexportMap,
extern_renames: ExternRenameMap,
}
fn extern_resolution(
src_dir: &Path,
root_file: &Path,
crate_package: &str,
dep_names: &[String],
items: &[syn::Item],
) -> Result<ExternResolution, String> {
let externs = external_crate_set(dep_names);
let externs_type: HashSet<String> = externs
.difference(&local_type_namespace_names(items))
.cloned()
.collect();
let scan = scan_crate(src_dir, root_file, crate_package, &externs)?;
Ok(ExternResolution {
externs,
externs_type,
reexports: scan.reexports,
extern_renames: scan.extern_renames,
})
}
fn resolve_principal(
path: &syn::Path,
uses: &UseMap,
module: &str,
externs: &HashSet<String>,
externs_type: &HashSet<String>,
extern_renames: &ExternRenameMap,
reexports: &ReexportMap,
) -> Option<String> {
let resolved = if path.leading_colon.is_some() {
extern_verbatim_renamed(path, externs, extern_renames)
} else {
resolve_path(path, uses, module, BareFallback::Ignore)
.or_else(|| extern_verbatim_renamed(path, externs_type, extern_renames))
};
resolved.map(|canonical| canonicalize_through_reexports(&canonical, reexports))
}
pub fn check_impl_trait(boundaries: &[ImplTraitBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_impl_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_impl_trait_boundary(
metadata: &Value,
boundary: &ImplTraitBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = if boundary.forbidden_operands.is_empty() {
impl_trait_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?
} else {
impl_trait_operand_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden_operands,
&boundary.crate_package,
&dependency_names(package),
)?
};
let module_file = seam_file(
&findings,
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
IMPL_TRAIT_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(module_file.clone()),
);
}
Ok(())
}
pub(crate) fn impl_trait_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_return_impl_traits(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures.into_iter().map(shape_finding).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub(crate) fn impl_trait_operand_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
dep_names: &[String],
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let ExternResolution {
externs,
externs_type,
reexports,
extern_renames,
} = extern_resolution(src_dir, root_file, crate_package, dep_names, &items)?;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_return_impl_traits(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures
.into_iter()
.filter(|exposure| {
forbidden.is_empty()
|| exposure
.principal
.as_ref()
.and_then(|path| {
resolve_principal(
path,
&uses,
module,
&externs,
&externs_type,
&extern_renames,
&reexports,
)
})
.is_some_and(|canonical| matches_forbidden(&canonical, &forbidden))
})
.map(shape_finding)
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
fn collect_item_return_impl_traits(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<ShapeExposure>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
let seam = fn_seam(module, &item.sig.ident);
out.extend(stamp_seam(impl_traits_in_return(&item.sig), &seam));
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
for trait_item in &item.items {
if let syn::TraitItem::Fn(method) = trait_item {
let seam = trait_method_seam(module, &trait_name, &method.sig.ident);
out.extend(stamp_seam(impl_traits_in_return(&method.sig), &seam));
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) {
let seam = inherent_method_seam(&owner, &method.sig.ident);
out.extend(stamp_seam(impl_traits_in_return(&method.sig), &seam));
}
}
}
}
_ => {}
}
}
fn impl_traits_in_return(sig: &syn::Signature) -> Vec<ShapeExposure> {
let mut collector = ImplTraitCollector::default();
if let syn::ReturnType::Type(_, ty) = &sig.output {
collector.visit_type(ty);
}
collector.exposures
}
pub fn check_async_exposure(boundaries: &[AsyncExposureBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_async_exposure_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_async_exposure_boundary(
metadata: &Value,
boundary: &AsyncExposureBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = async_exposure_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
let module_file = seam_file(
&findings,
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
ASYNC_EXPOSURE_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(module_file.clone()),
);
}
Ok(())
}
pub(crate) fn async_exposure_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut found = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_async_exposures(item, module, &uses, ordinal, &mut found);
}
found.sort();
found.dedup();
Ok(found)
}
fn collect_item_async_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<String>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
if item.sig.asyncness.is_some() {
out.push(
SemanticFinding::AsyncFreeFn {
module: module.to_string(),
name: strip_raw(&item.sig.ident.to_string()),
tail: render_sig_tail(&item.sig),
}
.to_string(),
);
}
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
for trait_item in &item.items {
if let syn::TraitItem::Fn(method) = trait_item {
if method.sig.asyncness.is_some() {
out.push(
SemanticFinding::AsyncTraitMethod {
module: module.to_string(),
trait_name: trait_name.clone(),
name: strip_raw(&method.sig.ident.to_string()),
tail: render_sig_tail(&method.sig),
}
.to_string(),
);
}
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) && method.sig.asyncness.is_some() {
out.push(
SemanticFinding::AsyncInherentMethod {
owner: owner.clone(),
name: strip_raw(&method.sig.ident.to_string()),
tail: render_sig_tail(&method.sig),
}
.to_string(),
);
}
}
}
}
_ => {}
}
}
fn render_sig_tail(sig: &syn::Signature) -> String {
let params: Vec<String> = sig
.inputs
.iter()
.map(|arg| match arg {
syn::FnArg::Receiver(receiver) => {
let reference = if receiver.reference.is_some() {
"&"
} else {
""
};
let mutability = if receiver.mutability.is_some() {
"mut "
} else {
""
};
format!("{reference}{mutability}self")
}
syn::FnArg::Typed(pat_type) => {
type_to_string(&pat_type.ty).unwrap_or_else(|| "_".to_string())
}
})
.collect();
let ret = match &sig.output {
syn::ReturnType::Type(_, ty) => {
format!(
" -> {}",
type_to_string(ty).unwrap_or_else(|| "_".to_string())
)
}
syn::ReturnType::Default => String::new(),
};
format!("({}){ret}", params.join(", "))
}
pub fn check_trait_impl_locality(
boundaries: &[TraitImplBoundary],
manifest_path: &Path,
) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_trait_impl_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_trait_impl_boundary(
metadata: &Value,
boundary: &TraitImplBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = trait_impl_findings(
src_dir,
&root_file,
&boundary.trait_path,
&boundary.allowed_locations,
&boundary.crate_package,
)?;
let rule = TRAIT_IMPL_RULE.to_string();
let mut file_cache: HashMap<String, Option<String>> = HashMap::new();
for (finding, module) in findings {
let file = per_finding_file(
&module,
src_dir,
&root_file,
&boundary.crate_package,
&mut file_cache,
);
violations.push(
Violation::new(
BoundaryKind::Semantic,
canonical_path_str(&boundary.trait_path),
rule.clone(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(file),
);
}
Ok(())
}
pub(crate) fn trait_impl_findings(
src_dir: &Path,
root_file: &Path,
trait_path: &str,
allowed: &[String],
crate_package: &str,
) -> Result<Vec<(String, String)>, String> {
let scan = scan_crate(src_dir, root_file, crate_package, &HashSet::new())?;
let given = canonical_path_str(trait_path);
let true_anchor = canonicalize_through_reexports(&given, &scan.reexports);
if !scan.trait_defs.contains(&true_anchor) {
return Err(unknown_trait_error(trait_path, crate_package));
}
let allowed: Vec<String> = allowed.iter().map(|a| canonical_path_str(a)).collect();
let mut findings = Vec::new();
for (ordinal, site) in scan.impls.iter().enumerate() {
let Some(resolved) = resolve_path(
&site.trait_path,
&site.uses,
&site.module,
BareFallback::CurrentModule,
) else {
continue;
};
let canonical = canonicalize_through_reexports(&resolved, &scan.reexports);
if canonical != true_anchor {
continue;
}
if matches_allowed(&site.module, &allowed) {
continue;
}
let owner = canonical_self_owner(&site.self_ty, &site.uses, &site.module, ordinal);
findings.push((
SemanticFinding::MisplacedImpl {
module: site.module.clone(),
owner,
}
.to_string(),
site.module.clone(),
));
}
findings.sort();
findings.dedup_by(|a, b| a.0 == b.0);
Ok(findings)
}
struct ImplSite {
module: String,
trait_path: syn::Path,
self_ty: syn::Type,
uses: UseMap,
}
struct TypeDef {
canonical: String,
module: String,
derives: Vec<syn::Path>,
}
struct CrateScan {
reexports: ReexportMap,
aliases: AliasMap,
extern_renames: ExternRenameMap,
trait_defs: HashSet<String>,
impls: Vec<ImplSite>,
type_defs: Vec<TypeDef>,
}
fn collect_crate_root_extern_renames(items: &[syn::Item], out: &mut ExternRenameMap) {
for item in items {
if let syn::Item::ExternCrate(ec) = item {
if let Some((_, rename)) = &ec.rename {
let alias = strip_raw(&rename.to_string());
let real = strip_raw(&ec.ident.to_string());
if alias != "_" && alias != real && real != "self" {
out.insert(alias, real);
}
}
}
}
}
fn bare_local_alias_target(
target: &syn::Path,
module: &str,
local_alias_names: &HashSet<String>,
) -> Option<String> {
if target.leading_colon.is_some() || target.segments.len() != 1 {
return None;
}
let seg = &target.segments[0];
if !matches!(seg.arguments, syn::PathArguments::None) {
return None;
}
let name = strip_raw(&seg.ident.to_string());
local_alias_names
.contains(&name)
.then(|| format!("{module}::{name}"))
}
fn scan_crate(
src_dir: &Path,
root_file: &Path,
crate_package: &str,
externs: &HashSet<String>,
) -> Result<CrateScan, String> {
let root = read_parse(root_file)?;
let mut scan = CrateScan {
reexports: ReexportMap::new(),
aliases: AliasMap::new(),
extern_renames: ExternRenameMap::new(),
trait_defs: HashSet::new(),
impls: Vec::new(),
type_defs: Vec::new(),
};
collect_crate_root_extern_renames(&root.items, &mut scan.extern_renames);
walk_module(
root.items,
"crate".to_string(),
src_dir.to_path_buf(),
crate_package,
externs,
&mut scan,
)?;
Ok(scan)
}
fn walk_module(
items: Vec<syn::Item>,
module: String,
child_dir: PathBuf,
crate_package: &str,
externs: &HashSet<String>,
scan: &mut CrateScan,
) -> Result<(), String> {
let uses = collect_uses(&items);
collect_reexports(
&items,
&module,
externs,
&scan.extern_renames,
&mut scan.reexports,
);
let externs_type: HashSet<String> = externs
.difference(&local_type_namespace_names(&items))
.cloned()
.collect();
let local_alias_names: HashSet<String> = items
.iter()
.filter_map(|it| match it {
syn::Item::Type(t) if t.generics.params.is_empty() => {
Some(strip_raw(&t.ident.to_string()))
}
_ => None,
})
.collect();
for item in &items {
match item {
syn::Item::Trait(trait_item) => {
scan.trait_defs.insert(format!(
"{module}::{}",
strip_raw(&trait_item.ident.to_string())
));
}
syn::Item::Impl(impl_item) if impl_item.trait_.is_some() => {
let (_, trait_path, _) = impl_item.trait_.as_ref().expect("trait_ is Some");
scan.impls.push(ImplSite {
module: module.clone(),
trait_path: trait_path.clone(),
self_ty: (*impl_item.self_ty).clone(),
uses: uses.clone(),
});
}
syn::Item::Struct(i) => {
push_type_def(&i.attrs, &i.ident, &module, scan)?;
}
syn::Item::Enum(i) => {
push_type_def(&i.attrs, &i.ident, &module, scan)?;
}
syn::Item::Union(i) => {
push_type_def(&i.attrs, &i.ident, &module, scan)?;
}
syn::Item::Type(type_item) if type_item.generics.params.is_empty() => {
if let Some(target) = alias_nominal_target(&type_item.ty) {
let alias = format!("{module}::{}", strip_raw(&type_item.ident.to_string()));
let resolved = if target.leading_colon.is_some() {
extern_verbatim_renamed(target, externs, &scan.extern_renames)
} else {
resolve_path(target, &uses, &module, BareFallback::Ignore)
.or_else(|| {
bare_local_alias_target(target, &module, &local_alias_names)
})
.or_else(|| {
extern_verbatim_renamed(target, &externs_type, &scan.extern_renames)
})
};
if let Some(resolved) = resolved {
if resolved != alias {
scan.aliases.insert(alias, resolved);
}
}
}
}
_ => {}
}
}
for item in items {
if let syn::Item::Mod(module_item) = item {
if has_path_attr(&module_item.attrs) {
continue;
}
let name = strip_raw(&module_item.ident.to_string());
let child_module = format!("{module}::{name}");
match module_item.content {
Some((_, inner)) => {
walk_module(
inner,
child_module,
child_dir.join(&name),
crate_package,
externs,
scan,
)?;
}
None => match locate_module_file(&child_dir, &name) {
Some(file) => {
let parsed = read_parse(&file)?;
walk_module(
parsed.items,
child_module,
child_dir.join(&name),
crate_package,
externs,
scan,
)?;
}
None => {
if !has_cfg_attr(&module_item.attrs) {
return Err(missing_module_file_error(&child_module, crate_package));
}
}
},
}
}
}
Ok(())
}
fn has_path_attr(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("path"))
}
fn push_type_def(
attrs: &[syn::Attribute],
ident: &syn::Ident,
module: &str,
scan: &mut CrateScan,
) -> Result<(), String> {
let name = strip_raw(&ident.to_string());
let derives = extract_derives(attrs)?;
scan.type_defs.push(TypeDef {
canonical: format!("{module}::{name}"),
module: module.to_string(),
derives,
});
Ok(())
}
fn extract_derives(attrs: &[syn::Attribute]) -> Result<Vec<syn::Path>, String> {
let mut out = Vec::new();
for attr in attrs {
if attr.path().is_ident("derive") {
out.extend(parse_derive_paths(&attr.meta)?);
} else if attr.path().is_ident("cfg_attr") {
let metas = attr
.parse_args_with(meta_list_parser())
.map_err(|e| format!("cannot parse #[cfg_attr(...)]: {e}"))?;
extract_derives_from_cfg_metas(&metas, &mut out)?;
}
}
Ok(out)
}
fn meta_list_parser() -> impl Parser<Output = syn::punctuated::Punctuated<syn::Meta, syn::Token![,]>>
{
syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated
}
fn parse_derive_paths(meta: &syn::Meta) -> Result<Vec<syn::Path>, String> {
let parser = syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated;
match meta {
syn::Meta::List(list) => Ok(list
.parse_args_with(parser)
.map_err(|e| format!("cannot parse derive(...): {e}"))?
.into_iter()
.collect()),
_ => Ok(Vec::new()),
}
}
fn extract_derives_from_cfg_metas(
metas: &syn::punctuated::Punctuated<syn::Meta, syn::Token![,]>,
out: &mut Vec<syn::Path>,
) -> Result<(), String> {
for meta in metas.iter().skip(1) {
if let syn::Meta::List(list) = meta {
if list.path.is_ident("derive") {
out.extend(parse_derive_paths(meta)?);
} else if list.path.is_ident("cfg_attr") {
let inner = list
.parse_args_with(meta_list_parser())
.map_err(|e| format!("cannot parse nested #[cfg_attr(...)]: {e}"))?;
extract_derives_from_cfg_metas(&inner, out)?;
}
}
}
Ok(())
}
fn has_cfg_attr(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("cfg"))
}
pub fn check_visibility(boundaries: &[VisibilityBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_visibility_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_visibility_boundary(
metadata: &Value,
boundary: &VisibilityBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = visibility_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
let module_file = seam_file(
&findings,
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
VISIBILITY_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(module_file.clone()),
);
}
Ok(())
}
pub(crate) fn visibility_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let mut findings: Vec<String> = items.iter().filter_map(pub_item_description).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
fn pub_item_description(item: &syn::Item) -> Option<String> {
match item {
syn::Item::Fn(i) if is_public(&i.vis) => Some(format!("pub fn {}", i.sig.ident)),
syn::Item::Struct(i) if is_public(&i.vis) => Some(format!("pub struct {}", i.ident)),
syn::Item::Enum(i) if is_public(&i.vis) => Some(format!("pub enum {}", i.ident)),
syn::Item::Union(i) if is_public(&i.vis) => Some(format!("pub union {}", i.ident)),
syn::Item::Type(i) if is_public(&i.vis) => Some(format!("pub type {}", i.ident)),
syn::Item::Const(i) if is_public(&i.vis) => Some(format!("pub const {}", i.ident)),
syn::Item::Static(i) if is_public(&i.vis) => Some(format!("pub static {}", i.ident)),
syn::Item::Trait(i) if is_public(&i.vis) => Some(format!("pub trait {}", i.ident)),
syn::Item::TraitAlias(i) if is_public(&i.vis) => {
Some(format!("pub trait {} (alias)", i.ident))
}
syn::Item::Mod(i) if is_public(&i.vis) => Some(format!("pub mod {}", i.ident)),
syn::Item::ExternCrate(i) if is_public(&i.vis) => {
Some(format!("pub extern crate {}", i.ident))
}
syn::Item::Use(i) if is_public(&i.vis) => Some(format!(
"pub use {}{}",
if i.leading_colon.is_some() { "::" } else { "" },
use_tree_desc(&i.tree)
)),
_ => None,
}
}
fn use_tree_desc(tree: &syn::UseTree) -> String {
match tree {
syn::UseTree::Path(p) => {
format!(
"{}::{}",
strip_raw(&p.ident.to_string()),
use_tree_desc(&p.tree)
)
}
syn::UseTree::Name(n) => strip_raw(&n.ident.to_string()),
syn::UseTree::Rename(r) => format!(
"{} as {}",
strip_raw(&r.ident.to_string()),
strip_raw(&r.rename.to_string())
),
syn::UseTree::Glob(_) => "*".to_string(),
syn::UseTree::Group(g) => {
let inner: Vec<String> = g.items.iter().map(use_tree_desc).collect();
format!("{{{}}}", inner.join(", "))
}
}
}
pub fn check_forbidden_marker(
boundaries: &[ForbiddenMarkerBoundary],
manifest_path: &Path,
) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_forbidden_marker_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_forbidden_marker_boundary(
metadata: &Value,
boundary: &ForbiddenMarkerBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let (_package, root_file, src_dir) = resolve_crate(metadata, &boundary.crate_package)?;
let src_dir = src_dir.as_path();
let findings = forbidden_marker_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden,
&boundary.crate_package,
)?;
let mut file_cache: HashMap<String, Option<String>> = HashMap::new();
for (finding, module) in findings {
let file = per_finding_file(
&module,
src_dir,
&root_file,
&boundary.crate_package,
&mut file_cache,
);
violations.push(
Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
FORBIDDEN_MARKER_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
)
.with_file(file),
);
}
Ok(())
}
pub(crate) fn forbidden_marker_findings(
src_dir: &Path,
root_file: &Path,
subtree: &str,
forbidden: &[String],
crate_package: &str,
) -> Result<Vec<(String, String)>, String> {
let scan = scan_crate(src_dir, root_file, crate_package, &HashSet::new())?;
let subtree = canonical_path_str(subtree);
let mut findings = Vec::new();
for entry in forbidden {
let entry_leaf = leaf_of(entry);
for td in &scan.type_defs {
if !under_subtree(&td.canonical, &subtree) {
continue;
}
for derived in &td.derives {
if path_leaf(derived) == entry_leaf {
findings.push((
SemanticFinding::ForbiddenDerive {
marker: entry.clone(),
canonical: td.canonical.clone(),
}
.to_string(),
td.module.clone(),
));
}
}
}
for site in &scan.impls {
if path_leaf(&site.trait_path) != entry_leaf {
continue;
}
let Some(self_canonical) = resolve_self_type(&site.self_ty, &site.uses, &site.module)
else {
continue; };
if under_subtree(&self_canonical, &subtree) {
findings.push((
format!("impl {entry} for {self_canonical}"),
site.module.clone(),
));
}
}
}
findings.sort();
findings.dedup_by(|a, b| a.0 == b.0);
Ok(findings)
}
fn path_within(path: &str, prefix: &str) -> bool {
path == prefix || path.starts_with(&format!("{prefix}::"))
}
fn under_subtree(canonical: &str, subtree: &str) -> bool {
path_within(canonical, subtree)
}
fn leaf_of(path: &str) -> &str {
path.rsplit("::").next().unwrap_or(path)
}
fn path_leaf(path: &syn::Path) -> String {
path.segments
.last()
.map(|s| strip_raw(&s.ident.to_string()))
.unwrap_or_default()
}
fn resolve_self_type(self_ty: &syn::Type, uses: &UseMap, module: &str) -> Option<String> {
match self_ty {
syn::Type::Path(tp) => resolve_path(&tp.path, uses, module, BareFallback::CurrentModule),
_ => None,
}
}
fn crate_root_file(package: &Value) -> Option<PathBuf> {
let targets = package["targets"].as_array()?;
let has_kind = |target: &Value, wanted: &str| {
target["kind"]
.as_array()
.map(|kinds| kinds.iter().any(|k| k.as_str() == Some(wanted)))
.unwrap_or(false)
};
let pick = targets
.iter()
.find(|t| has_kind(t, "lib"))
.or_else(|| targets.iter().find(|t| has_kind(t, "bin")))?;
pick["src_path"].as_str().map(PathBuf::from)
}
fn module_segments(module: &str) -> Vec<String> {
module
.split("::")
.map(strip_raw)
.enumerate()
.filter(|(i, seg)| !(*i == 0 && seg == "crate"))
.map(|(_, seg)| seg)
.filter(|seg| !seg.is_empty())
.collect()
}
fn resolve_module_items(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<syn::Item>, String> {
resolve_module(src_dir, root_file, module, crate_package).map(|(items, _file)| items)
}
fn resolve_module_file(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<PathBuf, String> {
resolve_module(src_dir, root_file, module, crate_package).map(|(_items, file)| file)
}
fn resolve_module(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<(Vec<syn::Item>, PathBuf), String> {
let root = read_parse(root_file)?;
let segments = module_segments(module);
descend(
root.items,
src_dir.to_path_buf(),
root_file.to_path_buf(),
&segments,
module,
crate_package,
)
}
fn descend(
items: Vec<syn::Item>,
child_dir: PathBuf,
current_file: PathBuf,
segments: &[String],
module: &str,
crate_package: &str,
) -> Result<(Vec<syn::Item>, PathBuf), String> {
let Some(seg) = segments.first() else {
return Ok((items, current_file));
};
for item in &items {
if let syn::Item::Mod(module_item) = item {
if has_path_attr(&module_item.attrs) {
continue;
}
if strip_raw(&module_item.ident.to_string()) != *seg {
continue;
}
match &module_item.content {
Some((_, inner)) => {
return descend(
inner.clone(),
child_dir.join(seg),
current_file,
&segments[1..],
module,
crate_package,
);
}
None => {
let file = locate_module_file(&child_dir, seg)
.ok_or_else(|| missing_module_file_error(module, crate_package))?;
let parsed = read_parse(&file)?;
return descend(
parsed.items,
child_dir.join(seg),
file,
&segments[1..],
module,
crate_package,
);
}
}
}
}
Err(unknown_module_error(module, crate_package))
}
fn locate_module_file(child_dir: &Path, seg: &str) -> Option<PathBuf> {
let flat = child_dir.join(format!("{seg}.rs"));
if flat.is_file() {
return Some(flat);
}
let nested = child_dir.join(seg).join("mod.rs");
if nested.is_file() {
return Some(nested);
}
None
}
fn read_parse(file: &Path) -> Result<syn::File, String> {
let text = std::fs::read_to_string(file)
.map_err(|err| unreadable_source_error(file, &err.to_string()))?;
syn::parse_file(&text).map_err(|err| unparseable_source_error(file, &err.to_string()))
}
fn matches_forbidden(canonical: &str, forbidden: &[String]) -> bool {
forbidden.iter().any(|entry| path_within(canonical, entry))
}
fn matches_allowed(location: &str, allowed: &[String]) -> bool {
allowed.iter().any(|entry| path_within(location, entry))
}
fn is_public(vis: &syn::Visibility) -> bool {
matches!(vis, syn::Visibility::Public(_))
}
fn paths_in_signature(sig: &syn::Signature) -> Vec<syn::Path> {
let mut c = PathCollector::default();
c.visit_signature(sig);
c.paths
}
fn paths_in_type(ty: &syn::Type) -> Vec<syn::Path> {
let mut c = PathCollector::default();
c.visit_type(ty);
c.paths
}
fn paths_in_generics(generics: &syn::Generics) -> Vec<syn::Path> {
let mut c = PathCollector::default();
c.visit_generics(generics);
c.paths
}
fn dyns_in_signature(sig: &syn::Signature) -> Vec<ShapeExposure> {
let mut c = DynCollector::default();
c.visit_signature(sig);
c.exposures
}
fn dyns_in_type(ty: &syn::Type) -> Vec<ShapeExposure> {
let mut c = DynCollector::default();
c.visit_type(ty);
c.exposures
}
fn dyns_in_generics(generics: &syn::Generics) -> Vec<ShapeExposure> {
let mut c = DynCollector::default();
c.visit_generics(generics);
c.exposures
}
struct PathExposure {
seam: String,
path: syn::Path,
is_reexport: bool,
}
enum SemanticFinding {
Exposed { subject: String, seam: String },
MisplacedImpl { module: String, owner: String },
ForbiddenDerive { marker: String, canonical: String },
AsyncFreeFn {
module: String,
name: String,
tail: String,
},
AsyncTraitMethod {
module: String,
trait_name: String,
name: String,
tail: String,
},
AsyncInherentMethod {
owner: String,
name: String,
tail: String,
},
}
impl std::fmt::Display for SemanticFinding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exposed { subject, seam } => write!(f, "{subject} exposed by {seam}"),
Self::MisplacedImpl { module, owner } => write!(f, "{module} (impl for {owner})"),
Self::ForbiddenDerive { marker, canonical } => {
write!(f, "derive {marker} on {canonical}")
}
Self::AsyncFreeFn { module, name, tail } => {
write!(f, "async fn {module}::{name}{tail}")
}
Self::AsyncTraitMethod {
module,
trait_name,
name,
tail,
} => write!(f, "async fn trait {module}::{trait_name}::{name}{tail}"),
Self::AsyncInherentMethod { owner, name, tail } => {
write!(f, "async fn <{owner}>::{name}{tail}")
}
}
}
}
fn shape_finding(exposure: ShapeExposure) -> String {
SemanticFinding::Exposed {
subject: exposure.shape,
seam: exposure.seam,
}
.to_string()
}
fn tag_paths(paths: Vec<syn::Path>, seam: &str) -> Vec<PathExposure> {
paths
.into_iter()
.map(|path| PathExposure {
seam: seam.to_string(),
path,
is_reexport: false,
})
.collect()
}
fn fn_seam(module: &str, name: &syn::Ident) -> String {
format!("fn {module}::{}", strip_raw(&name.to_string()))
}
fn inherent_method_seam(owner: &str, name: &syn::Ident) -> String {
format!("fn <{owner}>::{}", strip_raw(&name.to_string()))
}
fn trait_method_seam(module: &str, trait_name: &str, name: &syn::Ident) -> String {
format!(
"fn trait {module}::{trait_name}::{}",
strip_raw(&name.to_string())
)
}
fn item_seam(kind: &str, module: &str, name: &syn::Ident) -> String {
format!("{kind} {module}::{}", strip_raw(&name.to_string()))
}
fn field_seam(kind: &str, module: &str, owner: &str, member: &str) -> String {
format!("{kind} {module}::{owner}::{member}")
}
fn trait_assoc_seam(kind: &str, module: &str, trait_name: &str, name: &syn::Ident) -> String {
format!(
"{kind} trait {module}::{trait_name}::{}",
strip_raw(&name.to_string())
)
}
fn member_label(index: usize, field: &syn::Field) -> String {
match &field.ident {
Some(ident) => strip_raw(&ident.to_string()),
None => index.to_string(),
}
}
fn collect_item_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<PathExposure>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
let seam = fn_seam(module, &item.sig.ident);
out.extend(tag_paths(paths_in_signature(&item.sig), &seam));
}
syn::Item::Struct(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(tag_paths(
paths_in_generics(&item.generics),
&item_seam("struct", module, &item.ident),
));
for (index, field) in item.fields.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(tag_paths(paths_in_type(&field.ty), &seam));
}
}
}
syn::Item::Enum(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(tag_paths(
paths_in_generics(&item.generics),
&item_seam("enum", module, &item.ident),
));
for variant in &item.variants {
let owner = format!("{name}::{}", strip_raw(&variant.ident.to_string()));
for (index, field) in variant.fields.iter().enumerate() {
let seam = field_seam("variant", module, &owner, &member_label(index, field));
out.extend(tag_paths(paths_in_type(&field.ty), &seam));
}
}
}
syn::Item::Union(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(tag_paths(
paths_in_generics(&item.generics),
&item_seam("union", module, &item.ident),
));
for (index, field) in item.fields.named.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(tag_paths(paths_in_type(&field.ty), &seam));
}
}
}
syn::Item::Type(item) if is_public(&item.vis) => {
let seam = item_seam("type", module, &item.ident);
out.extend(tag_paths(paths_in_generics(&item.generics), &seam));
out.extend(tag_paths(paths_in_type(&item.ty), &seam));
}
syn::Item::Const(item) if is_public(&item.vis) => {
out.extend(tag_paths(
paths_in_type(&item.ty),
&item_seam("const", module, &item.ident),
));
}
syn::Item::Static(item) if is_public(&item.vis) => {
out.extend(tag_paths(
paths_in_type(&item.ty),
&item_seam("static", module, &item.ident),
));
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
let trait_seam = item_seam("trait", module, &item.ident);
out.extend(tag_paths(paths_in_generics(&item.generics), &trait_seam));
for bound in &item.supertraits {
if let syn::TypeParamBound::Trait(trait_bound) = bound {
out.push(PathExposure {
seam: trait_seam.clone(),
path: trait_bound.path.clone(),
is_reexport: false,
});
}
}
for trait_item in &item.items {
match trait_item {
syn::TraitItem::Fn(method) => {
let seam = trait_method_seam(module, &trait_name, &method.sig.ident);
out.extend(tag_paths(paths_in_signature(&method.sig), &seam));
}
syn::TraitItem::Type(assoc) => {
let seam = trait_assoc_seam("type", module, &trait_name, &assoc.ident);
for bound in &assoc.bounds {
if let syn::TypeParamBound::Trait(trait_bound) = bound {
out.push(PathExposure {
seam: seam.clone(),
path: trait_bound.path.clone(),
is_reexport: false,
});
}
}
}
syn::TraitItem::Const(assoc) => {
let seam = trait_assoc_seam("const", module, &trait_name, &assoc.ident);
out.extend(tag_paths(paths_in_type(&assoc.ty), &seam));
}
_ => {}
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) {
let seam = inherent_method_seam(&owner, &method.sig.ident);
out.extend(tag_paths(paths_in_signature(&method.sig), &seam));
}
}
}
}
syn::Item::Use(item) if is_public(&item.vis) => {
walk_reexport_tree(&item.tree, Vec::new(), module, out);
}
syn::Item::ExternCrate(item) if is_public(&item.vis) && item.ident != "self" => {
let name = strip_raw(&item.ident.to_string());
out.push(PathExposure {
seam: format!("pub extern crate {name}"),
path: syn::Path::from(item.ident.clone()),
is_reexport: true,
});
}
_ => {}
}
}
fn is_self_segment(ident: &syn::Ident) -> bool {
ident == "self"
}
fn walk_reexport_tree(
tree: &syn::UseTree,
prefix: Vec<syn::Ident>,
module: &str,
out: &mut Vec<PathExposure>,
) {
match tree {
syn::UseTree::Path(path) => {
let mut segs = prefix;
segs.push(path.ident.clone());
walk_reexport_tree(&path.tree, segs, module, out);
}
syn::UseTree::Name(name) => {
if is_self_segment(&name.ident) {
let exported = prefix.last().map(seg_name);
push_reexport(&prefix, exported.as_deref(), module, out);
} else {
let exported = seg_name(&name.ident);
let mut segs = prefix;
segs.push(name.ident.clone());
push_reexport(&segs, Some(&exported), module, out);
}
}
syn::UseTree::Rename(rename) => {
let alias = seg_name(&rename.rename);
if alias == "_" {
return; }
if is_self_segment(&rename.ident) {
push_reexport(&prefix, Some(&alias), module, out);
} else {
let mut segs = prefix;
segs.push(rename.ident.clone());
push_reexport(&segs, Some(&alias), module, out);
}
}
syn::UseTree::Glob(_) => {
push_reexport(&prefix, Some("*"), module, out);
}
syn::UseTree::Group(group) => {
for item in &group.items {
walk_reexport_tree(item, prefix.clone(), module, out);
}
}
}
}
fn seg_name(ident: &syn::Ident) -> String {
strip_raw(&ident.to_string())
}
fn push_reexport(
segs: &[syn::Ident],
exported: Option<&str>,
module: &str,
out: &mut Vec<PathExposure>,
) {
let (Some(exported), false) = (exported, segs.is_empty()) else {
return;
};
let segments = segs
.iter()
.map(|ident| syn::PathSegment {
ident: ident.clone(),
arguments: syn::PathArguments::None,
})
.collect();
out.push(PathExposure {
path: syn::Path {
leading_colon: None,
segments,
},
seam: format!("pub use {module}::{exported}"),
is_reexport: true,
});
}
fn paths_in_return(sig: &syn::Signature) -> Vec<syn::Path> {
let mut c = PathCollector::default();
if let syn::ReturnType::Type(_, ty) = &sig.output {
c.visit_type(ty);
}
c.paths
}
fn paths_in_bounds(
bounds: &syn::punctuated::Punctuated<syn::TypeParamBound, syn::token::Plus>,
) -> Vec<syn::Path> {
let mut c = PathCollector::default();
for bound in bounds {
c.visit_type_param_bound(bound);
}
c.paths
}
fn collect_trait_impl_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<PathExposure>,
) {
let syn::Item::Impl(item) = item else { return };
let Some((_, trait_path, _)) = &item.trait_ else {
return; };
let trait_label = path_to_string(trait_path).unwrap_or_else(|| format!("trait_#{ordinal}"));
let self_label = canonical_self_owner(&item.self_ty, uses, module, ordinal);
let prefix = format!("impl {trait_label} for {self_label}");
if let Some(syn::PathArguments::AngleBracketed(args)) =
trait_path.segments.last().map(|s| &s.arguments)
{
let seam = format!("{prefix} (trait-arg)");
for arg in &args.args {
match arg {
syn::GenericArgument::Type(ty) => out.extend(tag_paths(paths_in_type(ty), &seam)),
syn::GenericArgument::AssocType(at) => {
out.extend(tag_paths(paths_in_type(&at.ty), &seam))
}
_ => {}
}
}
}
out.extend(tag_paths(
paths_in_type(&item.self_ty),
&format!("{prefix} (self)"),
));
for param in &item.generics.params {
match param {
syn::GenericParam::Type(tp) => {
let key = strip_raw(&tp.ident.to_string());
let seam = format!("{prefix} (where {key})");
out.extend(tag_paths(paths_in_bounds(&tp.bounds), &seam));
}
syn::GenericParam::Const(cp) => {
let key = strip_raw(&cp.ident.to_string());
let seam = format!("{prefix} (where {key})");
out.extend(tag_paths(paths_in_type(&cp.ty), &seam));
}
syn::GenericParam::Lifetime(_) => {}
}
}
if let Some(where_clause) = &item.generics.where_clause {
for predicate in &where_clause.predicates {
if let syn::WherePredicate::Type(pt) = predicate {
let key = type_to_string(&pt.bounded_ty).unwrap_or_else(|| "_".to_string());
let seam = format!("{prefix} (where {key})");
out.extend(tag_paths(paths_in_type(&pt.bounded_ty), &seam));
out.extend(tag_paths(paths_in_bounds(&pt.bounds), &seam));
}
}
}
for impl_item in &item.items {
match impl_item {
syn::ImplItem::Type(assoc) => {
let seam = format!("{prefix} (assoc {})", strip_raw(&assoc.ident.to_string()));
out.extend(tag_paths(paths_in_type(&assoc.ty), &seam));
}
syn::ImplItem::Const(assoc) => {
let seam = format!("{prefix} (assoc {})", strip_raw(&assoc.ident.to_string()));
out.extend(tag_paths(paths_in_type(&assoc.ty), &seam));
}
syn::ImplItem::Fn(method) => {
let seam = format!(
"{prefix} (method {} return)",
strip_raw(&method.sig.ident.to_string())
);
out.extend(tag_paths(paths_in_return(&method.sig), &seam));
}
_ => {}
}
}
}
fn collect_item_dyn_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<ShapeExposure>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
let seam = fn_seam(module, &item.sig.ident);
out.extend(stamp_seam(dyns_in_signature(&item.sig), &seam));
}
syn::Item::Struct(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("struct", module, &item.ident),
));
for (index, field) in item.fields.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(stamp_seam(dyns_in_type(&field.ty), &seam));
}
}
}
syn::Item::Enum(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("enum", module, &item.ident),
));
for variant in &item.variants {
let owner = format!("{name}::{}", strip_raw(&variant.ident.to_string()));
for (index, field) in variant.fields.iter().enumerate() {
let seam = field_seam("variant", module, &owner, &member_label(index, field));
out.extend(stamp_seam(dyns_in_type(&field.ty), &seam));
}
}
}
syn::Item::Union(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("union", module, &item.ident),
));
for (index, field) in item.fields.named.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(stamp_seam(dyns_in_type(&field.ty), &seam));
}
}
}
syn::Item::Type(item) if is_public(&item.vis) => {
let seam = item_seam("type", module, &item.ident);
out.extend(stamp_seam(dyns_in_generics(&item.generics), &seam));
out.extend(stamp_seam(dyns_in_type(&item.ty), &seam));
}
syn::Item::Const(item) if is_public(&item.vis) => {
out.extend(stamp_seam(
dyns_in_type(&item.ty),
&item_seam("const", module, &item.ident),
));
}
syn::Item::Static(item) if is_public(&item.vis) => {
out.extend(stamp_seam(
dyns_in_type(&item.ty),
&item_seam("static", module, &item.ident),
));
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("trait", module, &item.ident),
));
for trait_item in &item.items {
match trait_item {
syn::TraitItem::Fn(method) => {
let seam = trait_method_seam(module, &trait_name, &method.sig.ident);
out.extend(stamp_seam(dyns_in_signature(&method.sig), &seam));
}
syn::TraitItem::Type(assoc) => {
if let Some((_, default)) = &assoc.default {
let seam = trait_assoc_seam("type", module, &trait_name, &assoc.ident);
out.extend(stamp_seam(dyns_in_type(default), &seam));
}
}
syn::TraitItem::Const(assoc) => {
let seam = trait_assoc_seam("const", module, &trait_name, &assoc.ident);
out.extend(stamp_seam(dyns_in_type(&assoc.ty), &seam));
}
_ => {}
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) {
let seam = inherent_method_seam(&owner, &method.sig.ident);
out.extend(stamp_seam(dyns_in_signature(&method.sig), &seam));
}
}
}
}
_ => {}
}
}
fn cargo_metadata(manifest_path: &Path) -> Result<Value, String> {
let output = Command::new("cargo")
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
])
.arg(manifest_path)
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
serde_json::from_slice(&output.stdout).map_err(|err| err.to_string())
}
fn find_package<'a>(metadata: &'a Value, package: &str) -> Option<&'a Value> {
metadata["packages"]
.as_array()?
.iter()
.find(|candidate| candidate["name"].as_str() == Some(package))
}
#[cfg(test)]
mod tests;