use alloc::collections::BTreeSet;
use alloc::string::String;
use alloc::string::ToString;
use alloc::vec::Vec;
use std::eprintln;
use std::format;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub(crate) struct Target {
pub(crate) name: String,
pub(crate) src: PathBuf,
pub(crate) doc: bool,
pub(crate) library: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Workspace {
pub(crate) root: PathBuf,
pub(crate) targets: Vec<Target>,
}
pub(crate) fn workspace(
root: &std::path::Path,
package: Option<&str>,
all_targets: bool,
) -> crate::Result<Workspace> {
let metadata = cargo_metadata::MetadataCommand::new()
.current_dir(root)
.no_deps()
.exec()?;
let mut targets = Vec::new();
for package in metadata
.packages
.iter()
.filter(|p| package.is_none_or(|want| p.name == want))
{
for target in &package.targets {
targets.push(Target {
name: target.name.clone(),
src: target.src_path.clone().into(),
doc: scan_target(&target.kind, all_targets),
library: target.kind.iter().any(|kind| {
matches!(
kind,
cargo_metadata::TargetKind::Lib
| cargo_metadata::TargetKind::RLib
| cargo_metadata::TargetKind::DyLib
)
}),
});
}
}
Ok(Workspace {
root: metadata.workspace_root.clone().into(),
targets,
})
}
fn scan_target(kinds: &[cargo_metadata::TargetKind], all_targets: bool) -> bool {
use cargo_metadata::TargetKind as CargoTargetKind;
if kinds
.iter()
.any(|k| matches!(k, CargoTargetKind::Lib | CargoTargetKind::ProcMacro))
{
return true;
}
all_targets
&& kinds
.iter()
.any(|k| matches!(k, CargoTargetKind::Bin | CargoTargetKind::Example))
}
pub(crate) fn module_tree(target: &Target) -> crate::Result<Vec<crate::SourceFile>> {
let mut out = Vec::new();
let mut visited = BTreeSet::new();
let mut off = alloc::collections::VecDeque::new();
let root = Child {
path: target.src.clone(),
segments: Vec::new(),
mod_rs: true,
rustdoc: target.doc,
};
collect(root, &mut out, &mut visited, &mut off)?;
while let Some(child) = off.pop_front() {
collect(child, &mut out, &mut visited, &mut off)?;
}
Ok(out)
}
fn collect(
file: Child,
out: &mut Vec<crate::SourceFile>,
visited: &mut BTreeSet<PathBuf>,
off: &mut alloc::collections::VecDeque<Child>,
) -> crate::Result<()> {
let canonical = match std::fs::canonicalize(&file.path) {
Ok(canonical) => canonical,
Err(err) => {
eprintln!("dejadoc: cannot read {}: {err}", file.path.display());
return Ok(());
}
};
if !visited.insert(canonical) {
return Ok(());
}
let text = match std::fs::read_to_string(&file.path) {
Ok(text) => text,
Err(err) => {
eprintln!("dejadoc: cannot read {}: {err}", file.path.display());
return Ok(());
}
};
let parsed = match syn::parse_file(&text) {
Ok(parsed) => parsed,
Err(err) => {
eprintln!("dejadoc: cannot parse {}: {err}", file.path.display());
return Ok(());
}
};
let dir = file
.path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let base = match file.path.file_stem() {
Some(stem) if !file.mod_rs => dir.join(stem),
_ => dir.to_path_buf(),
};
let mut children = Vec::new();
mod_decls(
&parsed.items,
dir,
&base,
&file.segments,
file.rustdoc,
&mut children,
);
out.push(crate::SourceFile {
path: file.path.to_string_lossy().into_owned(),
segments: file.segments,
parsed,
text,
rustdoc: file.rustdoc,
});
for child in children {
if child.rustdoc {
collect(child, out, visited, off)?;
} else {
off.push_back(child);
}
}
Ok(())
}
fn mod_decls(
items: &[syn::Item],
dir: &std::path::Path,
base: &std::path::Path,
prefix: &[String],
rustdoc: bool,
out: &mut Vec<Child>,
) {
use syn::{Item, ext::IdentExt};
for item in items {
let Item::Mod(moditem) = item else {
continue;
};
let rustdoc = rustdoc && crate::cfg::allows(&moditem.attrs);
let name = moditem.ident.unraw().to_string();
let mut segments = prefix.to_vec();
if let Some((_, children)) = &moditem.content {
let inner = declared_path(&moditem.attrs)
.map_or_else(|| base.join(&name), |path| lexical_join(dir, &path));
segments.push(moditem.ident.to_string());
mod_decls(children, &inner, &inner, &segments, rustdoc, out);
continue;
}
match resolve_mod_path(dir, base, &name, &moditem.attrs) {
(path, mod_rs) if path.exists() => {
segments.push(moditem.ident.to_string());
out.push(Child {
path,
segments,
mod_rs,
rustdoc,
});
}
(path, _) if rustdoc => {
eprintln!("dejadoc: missing module file {}", path.display());
}
_ => {}
}
}
}
struct Child {
path: PathBuf,
segments: Vec<String>,
mod_rs: bool,
rustdoc: bool,
}
fn declared_path(attrs: &[syn::Attribute]) -> Option<String> {
use syn::Meta;
let path = |meta: &Meta| match meta {
Meta::NameValue(nv) if nv.path.is_ident("path") => {
crate::cfg::lit_str(&nv.value).map(syn::LitStr::value)
}
_ => None,
};
attrs.iter().find_map(|attr| path(&attr.meta)).or_else(|| {
attrs
.iter()
.find_map(|attr| crate::cfg::cfg_attr_metas(attr)?.iter().find_map(path))
})
}
fn resolve_mod_path(
dir: &std::path::Path,
base: &std::path::Path,
name: &str,
attrs: &[syn::Attribute],
) -> (PathBuf, bool) {
if let Some(path) = declared_path(attrs) {
return (lexical_join(dir, &path), true);
}
let plain = base.join(format!("{name}.rs"));
if plain.exists() {
return (plain, false);
}
(base.join(name).join("mod.rs"), true)
}
fn lexical_join(dir: &std::path::Path, path: &str) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in dir.join(path).components() {
match (component, out.components().next_back()) {
(Component::CurDir, _)
| (Component::ParentDir, Some(Component::RootDir | Component::Prefix(_))) => {}
(Component::ParentDir, Some(Component::Normal(_))) => {
out.pop();
}
(other, _) => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn target(src: &std::path::Path) -> Target {
Target {
name: "mycrate".into(),
src: src.to_path_buf(),
doc: true,
library: true,
}
}
fn walk(files: &[(&str, &str)]) -> Vec<(String, String)> {
let dir = tempfile::tempdir().unwrap();
for (path, text) in files {
let path = dir.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
module_tree(&target(&dir.path().join(files[0].0)))
.unwrap()
.into_iter()
.map(|file| {
let path = std::path::Path::new(&file.path)
.strip_prefix(dir.path())
.unwrap();
(
path.to_string_lossy().into_owned(),
file.segments.join("::"),
)
})
.collect()
}
fn paths(files: &[(&str, &str)]) -> Vec<String> {
walk(files).into_iter().map(|(path, _)| path).collect()
}
#[test]
fn walks_declared_mod_files() {
let got = paths(&[
("lib.rs", "pub mod a;\npub fn f() {}\n"),
("a.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "a.rs"]);
}
#[test]
fn resolves_mod_dir_form() {
let got = paths(&[("lib.rs", "pub mod a;\n"), ("a/mod.rs", "pub fn g() {}\n")]);
assert_eq!(got, ["lib.rs", "a/mod.rs"]);
}
#[test]
fn cfg_attr_path_resolves_when_its_predicate_holds() {
let got = paths(&[
(
"lib.rs",
"#[cfg_attr(unix, path = \"other.rs\")]\npub mod renamed;\n",
),
("other.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "other.rs"]);
}
#[test]
fn cfg_attr_path_takes_the_holding_predicate_not_the_first_file() {
let got = paths(&[
(
"lib.rs",
"#[cfg_attr(windows, path = \"windows.rs\")]\n\
#[cfg_attr(unix, path = \"unix.rs\")]\n\
pub mod platform;\n",
),
("windows.rs", "pub fn w() {}\n"),
("unix.rs", "pub fn u() {}\n"),
]);
assert_eq!(got, ["lib.rs", "unix.rs"]);
}
#[test]
fn cfg_attr_path_with_a_failing_predicate_falls_back_to_the_plain_file() {
let got = paths(&[
(
"lib.rs",
"#[cfg_attr(windows, path = \"windows.rs\")]\npub mod platform;\n",
),
("windows.rs", "pub fn w() {}\n"),
("platform.rs", "pub fn p() {}\n"),
]);
assert_eq!(got, ["lib.rs", "platform.rs"]);
}
#[test]
fn cfg_attr_multi_token_predicate_resolves_path() {
let got = paths(&[
(
"lib.rs",
"#[cfg_attr(all(unix, not(windows)), path = \"other.rs\")]\npub mod renamed;\n",
),
("other.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "other.rs"]);
}
#[test]
fn cfg_attr_path_beside_other_attributes_resolves() {
let got = paths(&[
(
"lib.rs",
"#[cfg_attr(unix, doc = \"d.rs\", path = \"other.rs\")]\npub mod renamed;\n",
),
("other.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "other.rs"]);
}
#[test]
fn file_module_companion_directory() {
let got = paths(&[
("lib.rs", "pub mod de;\n"),
("de.rs", "pub mod child;\n"),
("de/child.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "de.rs", "de/child.rs"]);
}
#[test]
fn path_attribute_overrides_name() {
let got = paths(&[
("lib.rs", "#[path = \"other.rs\"]\npub mod renamed;\n"),
("other.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "other.rs"]);
}
#[test]
fn raw_identifier_module_file() {
let got = paths(&[
("lib.rs", "pub mod r#extern;\n"),
("extern.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "extern.rs"]);
}
#[test]
fn path_attribute_uses_the_defining_directory() {
let got = paths(&[
("lib.rs", "pub mod a;\n"),
("a.rs", "#[path = \"a/b.rs\"]\nmod b;\n"),
("a/b.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "a.rs", "a/b.rs"]);
}
#[test]
fn module_tree_reports_module_path_segments() {
let modules: Vec<String> = walk(&[
("lib.rs", "pub mod r#extern;\npub mod a;\n"),
("extern.rs", "pub fn g() {}\n"),
("a.rs", "pub mod b;\n"),
("a/b.rs", "pub fn h() {}\n"),
])
.into_iter()
.map(|(_, module)| module)
.collect();
assert_eq!(modules, ["", "r#extern", "a", "a::b"]);
}
#[test]
fn missing_mod_is_skipped_without_error() {
let got = paths(&[("lib.rs", "pub mod missing;\npub fn f() {}\n")]);
assert_eq!(got, ["lib.rs"]);
}
#[test]
fn cfg_off_mod_files_are_collected_outside_rustdoc() {
let dir = tempfile::tempdir().unwrap();
for (path, text) in [
(
"lib.rs",
"#[cfg(test)]\nmod tests;\n#[cfg(test)]\nmod inner { mod deep; }\n#[cfg(not(test))]\npub mod kept;\n#[cfg(windows)]\nmod absent;\n",
),
("tests.rs", "pub fn g() {}\n"),
("inner/deep.rs", "pub fn g() {}\n"),
("kept.rs", "pub fn g() {}\n"),
] {
let path = dir.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let got: Vec<(String, bool)> = module_tree(&target(&dir.path().join("lib.rs")))
.unwrap()
.into_iter()
.map(|f| (f.path.rsplit('/').next().unwrap().to_string(), f.rustdoc))
.collect();
let want = [
("lib.rs", true),
("kept.rs", true),
("tests.rs", false),
("deep.rs", false),
];
assert_eq!(got, want.map(|(p, r)| (p.to_string(), r)));
let mut lib = target(&dir.path().join("lib.rs"));
lib.doc = false;
assert!(module_tree(&lib).unwrap().iter().all(|f| !f.rustdoc));
}
#[test]
fn a_file_declared_under_exclusive_cfgs_stays_with_rustdoc() {
let dir = tempfile::tempdir().unwrap();
for (path, text) in [
(
"lib.rs",
"#[cfg(not(feature = \"x\"))]\npub(crate) mod exists;\n#[cfg(feature = \"x\")]\npub mod exists;\n",
),
("exists.rs", "#[cfg(test)]\nmod inner;\npub fn g() {}\n"),
("exists/inner.rs", "pub fn h() {}\n"),
] {
let path = dir.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let got: Vec<(String, bool)> = module_tree(&target(&dir.path().join("lib.rs")))
.unwrap()
.into_iter()
.map(|f| (f.path.rsplit('/').next().unwrap().to_string(), f.rustdoc))
.collect();
let want = [("lib.rs", true), ("exists.rs", true), ("inner.rs", false)];
assert_eq!(got, want.map(|(p, r)| (p.to_string(), r)));
}
#[test]
fn mod_decl_inside_inline_mod_resolves_under_the_inline_directory() {
let got = walk(&[
("lib.rs", "pub mod a { pub mod c; }\n"),
("a/c.rs", "pub fn g() {}\n"),
("c.rs", "pub fn stale() {}\n"),
]);
assert_eq!(
got,
[
("lib.rs".to_string(), String::new()),
("a/c.rs".to_string(), "a::c".to_string())
]
);
}
#[test]
fn mod_decl_inside_inline_mod_of_a_file_module_nests_under_its_directory() {
let got = paths(&[
("lib.rs", "pub mod x;\n"),
("x.rs", "pub mod a { pub mod c; }\n"),
("x/a/c.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "x.rs", "x/a/c.rs"]);
}
#[test]
fn nested_inline_mods_stack_their_directories() {
let got = paths(&[
("lib.rs", "pub mod a { pub mod b { pub mod c; } }\n"),
("a/b/c.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "a/b/c.rs"]);
}
#[test]
fn path_attribute_inside_inline_mod_resolves_under_the_inline_directory() {
let got = paths(&[
(
"lib.rs",
"pub mod a { #[path = \"other.rs\"] pub mod c; }\n",
),
("a/other.rs", "pub fn g() {}\n"),
("other.rs", "pub fn stale() {}\n"),
]);
assert_eq!(got, ["lib.rs", "a/other.rs"]);
}
#[test]
fn lexical_join_keeps_a_parent_it_cannot_cancel() {
let join = |dir: &str, path: &str| lexical_join(std::path::Path::new(dir), path);
assert_eq!(join("a/b", "./../c.rs"), PathBuf::from("a/c.rs"));
assert_eq!(join("a", "../../c.rs"), PathBuf::from("../c.rs"));
assert_eq!(join("/", "../c.rs"), PathBuf::from("/c.rs"));
}
#[test]
fn a_path_on_an_inline_mod_is_its_directory() {
let got = paths(&[
("lib.rs", "mod bigrand;\n"),
(
"bigrand.rs",
"#[path = \"bigrand\"]\nmod impl_a { mod traits; }\n",
),
("bigrand/traits.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "bigrand.rs", "bigrand/traits.rs"]);
let got = paths(&[
("lib.rs", "mod dom;\n"),
(
"dom/mod.rs",
"#[cfg_attr(all(), path = \".\")]\nmod feat { #[path = \"./fragment.rs\"] mod fragment; }\n",
),
("dom/fragment.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "dom/mod.rs", "dom/fragment.rs"]);
let got = paths(&[
("lib.rs", "mod sys;\n"),
("sys/mod.rs", "#[path = \"../shared.rs\"]\nmod shared;\n"),
("shared.rs", "pub fn g() {}\n"),
]);
assert_eq!(got, ["lib.rs", "sys/mod.rs", "shared.rs"]);
}
#[test]
fn root_with_a_directory_named_after_it_keeps_children_beside_it() {
let got = paths(&[
("lib.rs", "pub mod foo;\npub mod lib { pub mod child; }\n"),
("foo.rs", ""),
("lib/child.rs", ""),
("lib/foo.rs", "pub fn stale() {}\n"),
]);
assert_eq!(got, ["lib.rs", "foo.rs", "lib/child.rs"]);
}
#[test]
fn path_loaded_file_keeps_children_beside_it() {
let got = paths(&[
("lib.rs", "#[path = \"other.rs\"]\npub mod m;\n"),
("other.rs", "pub mod c;\n"),
("c.rs", ""),
("other/c.rs", "pub fn stale() {}\n"),
]);
assert_eq!(got, ["lib.rs", "other.rs", "c.rs"]);
}
#[test]
fn mod_rs_file_keeps_children_beside_it() {
let got = paths(&[
("lib.rs", "pub mod a;\n"),
("a/mod.rs", "pub mod b;\n"),
("a/b.rs", ""),
("a/mod/b.rs", "pub fn stale() {}\n"),
]);
assert_eq!(got, ["lib.rs", "a/mod.rs", "a/b.rs"]);
}
#[test]
fn module_cycles_terminate() {
let got = paths(&[
("lib.rs", "pub mod a;\n"),
("a.rs", "#[path = \"lib.rs\"]\npub mod back;\n"),
]);
assert_eq!(got, ["lib.rs", "a.rs"]);
}
#[test]
fn unparseable_file_is_skipped_without_error() {
assert_eq!(
paths(&[("lib.rs", "@@@ not rust @@ @\n")]),
Vec::<String>::new()
);
}
fn cargo_package(dir: &std::path::Path, name: &str, extra: &str) {
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n{extra}"
),
)
.unwrap();
std::fs::write(src.join("lib.rs"), "pub fn f() {}\n").unwrap();
}
#[test]
fn every_target_is_listed_and_lib_targets_scan_doctests_by_default() {
let dir = tempfile::tempdir().unwrap();
cargo_package(
dir.path(),
"ws",
"[[bin]]\nname = \"tool\"\npath = \"src/bin/tool.rs\"\n[workspace]\n",
);
let bin = dir.path().join("src").join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("tool.rs"), "fn main() {}\n").unwrap();
let ws = workspace(dir.path(), None, false).unwrap();
let targets: Vec<(&str, bool)> = ws
.targets
.iter()
.map(|t| (t.name.as_str(), t.doc))
.collect();
assert_eq!(targets, [("ws", true), ("tool", false)]);
assert!(ws.targets[0].src.ends_with("src/lib.rs"));
assert_eq!(ws.root, dir.path().to_path_buf());
}
#[test]
fn all_targets_scans_bin_and_example_doctests_never_test_targets() {
let dir = tempfile::tempdir().unwrap();
cargo_package(
dir.path(),
"ws",
"[[bin]]\nname = \"tool\"\npath = \"src/bin/tool.rs\"\n[workspace]\n",
);
for (path, text) in [
("src/bin/tool.rs", "fn main() {}\n"),
("examples/demo.rs", "fn main() {}\n"),
("tests/it.rs", "#[test]\nfn t() {}\n"),
] {
let path = dir.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let ws = workspace(dir.path(), None, true).unwrap();
let mut targets: Vec<(&str, bool)> = ws
.targets
.iter()
.map(|t| (t.name.as_str(), t.doc))
.collect();
targets.sort_unstable();
assert_eq!(
targets,
[("demo", true), ("it", false), ("tool", true), ("ws", true)]
);
}
#[test]
fn a_target_file_missing_from_the_package_is_skipped() {
let dir = tempfile::tempdir().unwrap();
cargo_package(
dir.path(),
"ws",
"[[bench]]\nname = \"gone\"\npath = \"benches/gone.rs\"\n[workspace]\n",
);
let ws = workspace(dir.path(), None, false).unwrap();
let files: Vec<usize> = ws
.targets
.iter()
.map(|t| module_tree(t).unwrap().len())
.collect();
assert_eq!(files, [1, 0]);
}
#[test]
fn package_filter_selects_one_member() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"alpha\", \"beta\"]\nresolver = \"2\"\n",
)
.unwrap();
cargo_package(dir.path().join("alpha").as_path(), "alpha", "");
cargo_package(dir.path().join("beta").as_path(), "beta", "");
let ws = workspace(dir.path(), Some("beta"), false).unwrap();
assert_eq!(ws.targets.len(), 1);
assert_eq!(ws.targets[0].name, "beta");
let ws = workspace(dir.path(), Some("nope"), false).unwrap();
assert!(ws.targets.is_empty());
}
}