use camino::{Utf8Path, Utf8PathBuf};
use syn::{Attribute, Item, Meta};
use crate::cfg::{CfgSet, test_gated_for};
use crate::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub(super) struct Declaration {
pub(super) target: Utf8PathBuf,
pub(super) excluded: bool,
}
#[must_use]
pub(super) fn declarations(path: &Utf8Path, ast: &syn::File, cfg: &CfgSet) -> Vec<Declaration> {
let mut found = Vec::new();
let (Some(directory), Some(beside)) = (owned_directory(path), path.parent()) else {
return found;
};
walk(&ast.items, &directory, beside, cfg, false, &mut found);
found
}
fn owned_directory(path: &Utf8Path) -> Option<Utf8PathBuf> {
let parent = path.parent()?;
let stem = path.file_stem()?;
if matches!(stem, "lib" | "main" | "mod") {
return Some(parent.to_owned());
}
Some(parent.join(stem))
}
fn walk(items: &[Item], directory: &Utf8Path, base: &Utf8Path, cfg: &CfgSet, inherited_exclusion: bool, found: &mut Vec<Declaration>) {
for item in items {
let Item::Mod(module) = item else { continue };
let excluded = inherited_exclusion || test_gated_for(cfg, &module.attrs) || !cfg.holds_for(&module.attrs);
if let Some((_brace, items)) = module.content.as_ref() {
let nested =
path_attribute(&module.attrs).map_or_else(|| directory.join(module.ident.to_string()), |relative| base.join(relative));
walk(items, &nested, &nested, cfg, excluded, found);
continue;
}
let name = module.ident.to_string();
let candidates = path_attribute(&module.attrs).map_or_else(
|| vec![directory.join(format!("{name}.rs")), directory.join(&name).join("mod.rs")],
|relative| vec![base.join(relative)],
);
for target in candidates {
if target.as_std_path().is_file() {
found.push(Declaration { target, excluded });
break;
}
}
}
}
fn path_attribute(attrs: &[Attribute]) -> Option<String> {
attrs.iter().find_map(|attr| {
let Meta::NameValue(pair) = &attr.meta else { return None };
if !pair.path.is_ident("path") {
return None;
}
let syn::Expr::Lit(literal) = &pair.value else { return None };
let syn::Lit::Str(text) = &literal.lit else { return None };
Some(text.value())
})
}
#[must_use]
pub(super) fn excluded_files(roots: &[Utf8PathBuf], declared: &[(Utf8PathBuf, Vec<Declaration>)]) -> HashSet<Utf8PathBuf> {
let edges: HashMap<&Utf8Path, &[Declaration]> = declared.iter().map(|(from, list)| (from.as_path(), list.as_slice())).collect();
let mut live: HashSet<Utf8PathBuf> = HashSet::default();
let mut queue: Vec<&Utf8Path> = roots.iter().map(Utf8PathBuf::as_path).collect();
while let Some(file) = queue.pop() {
if !live.insert(file.to_owned()) {
continue;
}
for declaration in edges.get(file).copied().unwrap_or(&[]) {
if !declaration.excluded {
queue.push(declaration.target.as_path());
}
}
}
let mut excluded: HashSet<Utf8PathBuf> = HashSet::default();
let mut queue: Vec<&Utf8Path> = declared
.iter()
.flat_map(|(_from, list)| list)
.filter(|declaration| declaration.excluded)
.map(|declaration| declaration.target.as_path())
.collect();
while let Some(file) = queue.pop() {
if live.contains(file) || !excluded.insert(file.to_owned()) {
continue;
}
for declaration in edges.get(file).copied().unwrap_or(&[]) {
queue.push(declaration.target.as_path());
}
}
excluded
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use super::*;
fn parse(text: &str) -> syn::File {
syn::parse_file(text).unwrap()
}
fn test_gated(attrs: &[Attribute]) -> bool {
test_gated_for(&CfgSet::default(), attrs)
}
#[test]
fn a_file_stem_that_is_not_a_module_root_owns_a_subdirectory() {
assert_eq!(owned_directory(Utf8Path::new("/a/src/lib.rs")), Some(Utf8PathBuf::from("/a/src")));
assert_eq!(owned_directory(Utf8Path::new("/a/src/mod.rs")), Some(Utf8PathBuf::from("/a/src")));
assert_eq!(owned_directory(Utf8Path::new("/a/src/de.rs")), Some(Utf8PathBuf::from("/a/src/de")));
}
#[test]
fn a_cfg_test_declaration_is_recognised() {
let ast = parse("fn not_a_module() {}\n#[cfg(test)]\nmod tests;\nmod real;");
let gated: Vec<bool> = ast
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) => Some(test_gated(&module.attrs)),
_ => None,
})
.collect();
assert_eq!(gated, vec![true, false]);
}
#[test]
fn a_compound_gate_is_read_all_the_way_down() {
let ast = parse(concat!(
"#[cfg(all(test, unix))]\nmod a;\n",
"#[cfg(not(feature = \"x\"))]\n#[cfg(test)]\nmod b;\n",
"#[cfg(all(unix, all(test, feature = \"x\")))]\nmod c;\n",
));
let gated: Vec<bool> = ast
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) => Some(test_gated(&module.attrs)),
_ => None,
})
.collect();
assert_eq!(gated, vec![true, true, true]);
}
#[test]
fn a_gate_a_production_build_can_also_satisfy_is_not_test_only() {
let ast = parse(concat!(
"#[cfg(any(test, feature = \"runtime\"))]\nmod a;\n",
"#[cfg(not(test))]\nmod b;\n",
"#[cfg(any(all(test, unix), windows))]\nmod c;\n",
));
let gated: Vec<bool> = ast
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) => Some(test_gated(&module.attrs)),
_ => None,
})
.collect();
assert_eq!(gated, vec![false, false, false]);
}
#[test]
fn a_path_attribute_is_read() {
let ast = parse("#[path = \"reader_tests.rs\"]\nmod tests;");
let Item::Mod(module) = &ast.items[0] else {
panic!("expected a module")
};
assert_eq!(path_attribute(&module.attrs), Some("reader_tests.rs".to_owned()));
}
#[test]
fn a_path_attribute_resolves_beside_the_file_that_wrote_it() {
let directory = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
let declaring = root.join("de").join("reader_impl_tests.rs");
let target = root.join("de").join("reader_tests.rs");
std::fs::create_dir_all(root.join("de").as_std_path()).unwrap();
std::fs::write(declaring.as_std_path(), "").unwrap();
std::fs::write(target.as_std_path(), "").unwrap();
let ast = parse("#[cfg(test)]\n#[path = \"reader_tests.rs\"]\nmod tests;");
let found = declarations(&declaring, &ast, &CfgSet::unconditional());
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].target, target);
assert!(found[0].excluded);
}
#[test]
fn an_active_cfg_attr_marks_an_external_module_as_test_only() {
let directory = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
let declaring = root.join("lib.rs");
let target = root.join("tests.rs");
std::fs::write(target.as_std_path(), "").unwrap();
let ast = parse("#[cfg_attr(unix, cfg(test))]\nmod tests;");
let active = declarations(&declaring, &ast, &CfgSet::parse("unix\n"));
let inactive = declarations(&declaring, &ast, &CfgSet::parse("windows\n"));
assert_eq!(active.len(), 1, "{active:?}");
assert_eq!(active[0].target, target);
assert!(active[0].excluded);
assert_eq!(inactive.len(), 1, "{inactive:?}");
assert!(!inactive[0].excluded);
}
#[test]
fn an_active_cfg_attr_excludes_an_external_module() {
let directory = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
let declaring = root.join("lib.rs");
let target = root.join("platform.rs");
std::fs::write(target.as_std_path(), "").unwrap();
let ast = parse("#[cfg_attr(unix, cfg(windows))]\nmod platform;");
let active = declarations(&declaring, &ast, &CfgSet::parse("unix\n"));
let inactive = declarations(&declaring, &ast, &CfgSet::parse("windows\n"));
let declared = vec![(declaring.clone(), active.clone())];
assert_eq!(active.len(), 1, "{active:?}");
assert!(active[0].excluded);
assert_eq!(inactive.len(), 1, "{inactive:?}");
assert!(!inactive[0].excluded);
assert!(excluded_files(&[declaring], &declared).contains(&target));
}
#[test]
fn a_plain_declaration_resolves_in_the_directory_the_file_owns() {
let directory = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
let declaring = root.join("de.rs");
let target = root.join("de").join("raw.rs");
std::fs::create_dir_all(root.join("de").as_std_path()).unwrap();
std::fs::write(target.as_std_path(), "").unwrap();
let found = declarations(&declaring, &parse("mod raw;"), &CfgSet::unconditional());
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].target, target);
assert!(!found[0].excluded);
}
#[test]
fn a_file_reached_only_through_a_test_module_is_excluded() {
let root = Utf8PathBuf::from("/w/src/lib.rs");
let helper = Utf8PathBuf::from("/w/src/helper.rs");
let tests = Utf8PathBuf::from("/w/src/reader_tests.rs");
let declared = vec![(
root.clone(),
vec![
Declaration {
target: helper.clone(),
excluded: false,
},
Declaration {
target: tests.clone(),
excluded: true,
},
],
)];
let excluded = excluded_files(&[root], &declared);
assert!(excluded.contains(&tests));
assert!(!excluded.contains(&helper));
}
#[test]
fn a_file_a_test_module_shares_with_real_code_is_kept() {
let root = Utf8PathBuf::from("/w/src/lib.rs");
let shared = Utf8PathBuf::from("/w/src/shared.rs");
let declared = vec![(
root.clone(),
vec![
Declaration {
target: shared.clone(),
excluded: true,
},
Declaration {
target: shared,
excluded: false,
},
],
)];
assert!(excluded_files(&[root], &declared).is_empty());
}
#[test]
fn a_file_nothing_declares_is_left_alone() {
let root = Utf8PathBuf::from("/w/src/lib.rs");
assert!(excluded_files(&[root], &[]).is_empty());
}
#[test]
fn a_module_below_a_test_module_is_excluded_too() {
let root = Utf8PathBuf::from("/w/src/lib.rs");
let outer = Utf8PathBuf::from("/w/src/outer.rs");
let inner = Utf8PathBuf::from("/w/src/outer/inner.rs");
let declared = vec![
(
root.clone(),
vec![Declaration {
target: outer.clone(),
excluded: true,
}],
),
(
outer.clone(),
vec![Declaration {
target: inner.clone(),
excluded: false,
}],
),
];
let excluded = excluded_files(&[root], &declared);
assert!(excluded.contains(&outer));
assert!(excluded.contains(&inner));
}
#[test]
fn a_file_declared_by_two_live_modules_is_walked_once() {
let root = Utf8PathBuf::from("/w/src/lib.rs");
let left = Utf8PathBuf::from("/w/src/left.rs");
let right = Utf8PathBuf::from("/w/src/right.rs");
let shared = Utf8PathBuf::from("/w/src/shared.rs");
let declared = vec![
(
root.clone(),
vec![
Declaration {
target: left.clone(),
excluded: false,
},
Declaration {
target: right.clone(),
excluded: false,
},
],
),
(
left,
vec![Declaration {
target: shared.clone(),
excluded: false,
}],
),
(
right,
vec![Declaration {
target: shared.clone(),
excluded: false,
}],
),
];
let excluded = excluded_files(&[root], &declared);
assert!(!excluded.contains(&shared), "{excluded:?}");
}
#[test]
fn a_cycle_between_two_files_terminates() {
let root = Utf8PathBuf::from("/w/src/lib.rs");
let first = Utf8PathBuf::from("/w/src/first.rs");
let second = Utf8PathBuf::from("/w/src/second.rs");
let declared = vec![
(
root.clone(),
vec![Declaration {
target: first.clone(),
excluded: true,
}],
),
(
first.clone(),
vec![Declaration {
target: second.clone(),
excluded: false,
}],
),
(
second.clone(),
vec![Declaration {
target: first.clone(),
excluded: false,
}],
),
];
let excluded = excluded_files(&[root], &declared);
assert!(excluded.contains(&first));
assert!(excluded.contains(&second));
}
#[test]
fn a_path_with_no_directory_declares_nothing() {
assert!(declarations(Utf8Path::new(""), &parse("mod raw;"), &CfgSet::unconditional()).is_empty());
}
#[test]
fn a_declaration_pointing_at_no_file_is_dropped() {
let directory = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
assert!(declarations(&root.join("lib.rs"), &parse("mod nowhere;"), &CfgSet::unconditional()).is_empty());
}
#[test]
fn an_attribute_that_is_neither_cfg_nor_path_is_ignored() {
let ast = parse("#[doc = \"a module\"]\n#[derive(Debug)]\nmod plain;");
let Item::Mod(module) = &ast.items[0] else {
panic!("expected a module")
};
assert_eq!(path_attribute(&module.attrs), None);
assert!(!test_gated(&module.attrs));
}
#[test]
fn a_path_attribute_on_an_inline_module_redirects_everything_below_it() {
let directory = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
let target = root.join("custom").join("inner.rs");
std::fs::create_dir_all(root.join("custom").as_std_path()).unwrap();
std::fs::write(target.as_std_path(), "").unwrap();
let source = "#[path = \"custom\"]\nmod outer { mod inner; }";
let found = declarations(&root.join("lib.rs"), &parse(source), &CfgSet::unconditional());
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].target, target);
}
}