use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
use syn::{Expr, ExprLit, ItemMod, Lit, Meta};
use walkdir::WalkDir;
pub(crate) fn file_path_to_module_path(file_path: &Path, src_root: &Path) -> String {
let relative = file_path.strip_prefix(src_root).unwrap_or(file_path);
let mut parts: Vec<String> = Vec::new();
for component in relative.components() {
if let Some(component_name) = component.as_os_str().to_str() {
parts.push(component_name.to_string());
}
}
if let Some(last) = parts.last().cloned() {
parts.pop();
match last.as_str() {
"lib.rs" | "main.rs" => {
}
"mod.rs" => {
}
_ => {
if let Some(stem) = last.strip_suffix(".rs") {
parts.push(stem.to_string());
} else {
parts.push(last);
}
}
}
}
parts.join("::")
}
pub(crate) fn normalize_exclude_path(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
pub(crate) fn rs_files(dir: &Path) -> impl Iterator<Item = PathBuf> {
WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(move |entry| {
let file_path = entry.path();
let file_path = file_path.strip_prefix(dir).unwrap_or(file_path);
!file_path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
is_skipped_component(&s)
}) && file_path.extension() == Some(OsStr::new("rs"))
})
.map(|e| e.path().to_path_buf())
}
pub(crate) fn rs_files_excluding_nested_packages(
dir: &Path,
manifest_path: &Path,
) -> impl Iterator<Item = PathBuf> {
let manifest_path = normalize_exclude_path(manifest_path);
WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_entry(move |entry| {
should_descend_workspace_source(entry.path(), dir, &manifest_path)
})
.filter_map(|e| e.ok())
.filter(move |entry| {
let file_path = entry.path();
let relative_path = file_path.strip_prefix(dir).unwrap_or(file_path);
!relative_path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
is_skipped_component(&s)
}) && file_path.extension() == Some(OsStr::new("rs"))
})
.map(|e| e.path().to_path_buf())
}
pub(crate) fn should_descend_workspace_source(
path: &Path,
root: &Path,
manifest_path: &Path,
) -> bool {
let relative_path = path.strip_prefix(root).unwrap_or(path);
if relative_path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
is_skipped_component(&s)
}) {
return false;
}
if is_manifest_level_non_source(path, manifest_path) {
return false;
}
if path.is_dir() {
let cargo_toml = path.join("Cargo.toml");
if cargo_toml.exists() && normalize_exclude_path(&cargo_toml) != manifest_path {
return false;
}
}
true
}
fn is_skipped_component(name: &str) -> bool {
name == "target" || name.starts_with('.')
}
fn is_manifest_level_non_source(path: &Path, manifest_path: &Path) -> bool {
let Some(manifest_dir) = manifest_path.parent() else {
return false;
};
let path = normalize_exclude_path(path);
let manifest_dir = normalize_exclude_path(manifest_dir);
path == manifest_dir.join("build.rs")
|| path == manifest_dir.join("tests")
|| path == manifest_dir.join("examples")
|| path == manifest_dir.join("benches")
}
#[derive(Debug, Clone)]
pub(crate) struct DiscoveredWorkspaceFile {
pub(crate) file_path: PathBuf,
pub(crate) crate_name: String,
pub(crate) source_root: PathBuf,
pub(crate) module_name: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ModuleTreeFile {
pub(crate) file_path: PathBuf,
pub(crate) module_name: String,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ModuleTreeDiscovery {
pub(crate) files: Vec<ModuleTreeFile>,
pub(crate) boundary_skipped_files: usize,
}
pub(crate) fn canonical_file_key(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| normalize_exclude_path(path))
}
pub(crate) fn discover_module_tree(
crate_root: &Path,
workspace_root: &Path,
manifest_path: &Path,
visited: &mut HashSet<PathBuf>,
source_contents: &mut HashMap<PathBuf, String>,
) -> ModuleTreeDiscovery {
let mut context = ModuleTreeContext {
workspace_root: canonical_file_key(workspace_root),
manifest_path: canonical_file_key(manifest_path),
visited,
source_contents,
discovery: ModuleTreeDiscovery::default(),
};
discover_module_tree_file(
crate_root,
String::new(),
crate_root.parent().unwrap_or_else(|| Path::new("")),
&mut context,
);
context.discovery
}
struct ModuleTreeContext<'a> {
workspace_root: PathBuf,
manifest_path: PathBuf,
visited: &'a mut HashSet<PathBuf>,
source_contents: &'a mut HashMap<PathBuf, String>,
discovery: ModuleTreeDiscovery,
}
fn discover_module_tree_file(
file_path: &Path,
module_name: String,
module_dir: &Path,
context: &mut ModuleTreeContext<'_>,
) {
if !file_path.exists() {
return;
}
let file_key = canonical_file_key(file_path);
if !context.visited.insert(file_key) {
return;
}
context.discovery.files.push(ModuleTreeFile {
file_path: file_path.to_path_buf(),
module_name: module_name.clone(),
});
let content = match context.source_contents.get(&canonical_file_key(file_path)) {
Some(content) => content.clone(),
None => {
let Ok(content) = fs::read_to_string(file_path) else {
return;
};
context
.source_contents
.insert(canonical_file_key(file_path), content.clone());
content
}
};
let Ok(parsed) = syn::parse_file(&content) else {
return;
};
discover_module_items(&parsed.items, module_dir, &module_name, context);
}
fn discover_module_items(
items: &[syn::Item],
module_dir: &Path,
parent_module: &str,
context: &mut ModuleTreeContext<'_>,
) {
for item in items {
let syn::Item::Mod(item_mod) = item else {
continue;
};
let child_name = item_mod.ident.to_string();
let child_module = join_module_path(parent_module, &child_name);
if let Some((_, inline_items)) = &item_mod.content {
let inline_module_dir = module_dir.join(&child_name);
discover_module_items(inline_items, &inline_module_dir, &child_module, context);
continue;
}
match resolve_external_module_file(
module_dir,
item_mod,
&context.workspace_root,
&context.manifest_path,
) {
ModuleFileResolution::Resolved(resolved_file) => {
let child_module_dir = module_dir_for_resolved_module(&resolved_file);
discover_module_tree_file(&resolved_file, child_module, &child_module_dir, context);
}
ModuleFileResolution::BoundarySkipped => {
context.discovery.boundary_skipped_files += 1;
}
ModuleFileResolution::NotFound => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ModuleFileResolution {
Resolved(PathBuf),
BoundarySkipped,
NotFound,
}
fn resolve_external_module_file(
module_dir: &Path,
item_mod: &ItemMod,
workspace_root: &Path,
manifest_path: &Path,
) -> ModuleFileResolution {
if let Some(path_attr) = path_attribute_value(&item_mod.attrs) {
return boundary_checked_module_file(
module_dir.join(path_attr),
workspace_root,
manifest_path,
);
}
let module_name = item_mod.ident.to_string();
let flat = module_dir.join(format!("{module_name}.rs"));
if flat.exists() {
return boundary_checked_module_file(flat, workspace_root, manifest_path);
}
let nested = module_dir.join(&module_name).join("mod.rs");
if nested.exists() {
boundary_checked_module_file(nested, workspace_root, manifest_path)
} else {
ModuleFileResolution::NotFound
}
}
fn boundary_checked_module_file(
candidate: PathBuf,
workspace_root: &Path,
manifest_path: &Path,
) -> ModuleFileResolution {
let Ok(canonical_candidate) = fs::canonicalize(&candidate) else {
return ModuleFileResolution::NotFound;
};
if !canonical_candidate.starts_with(workspace_root)
|| crosses_package_boundary(&canonical_candidate, workspace_root, manifest_path)
{
return ModuleFileResolution::BoundarySkipped;
}
ModuleFileResolution::Resolved(canonical_candidate)
}
fn crosses_package_boundary(
canonical_file: &Path,
workspace_root: &Path,
manifest_path: &Path,
) -> bool {
let mut current = canonical_file.parent();
while let Some(dir) = current {
if dir == workspace_root {
break;
}
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.exists() && canonical_file_key(&cargo_toml) != manifest_path {
return true;
}
current = dir.parent();
}
false
}
fn path_attribute_value(attrs: &[syn::Attribute]) -> Option<PathBuf> {
attrs.iter().find_map(|attr| {
if !attr.path().is_ident("path") {
return None;
}
match &attr.meta {
Meta::NameValue(name_value) => {
if let Expr::Lit(ExprLit {
lit: Lit::Str(value),
..
}) = &name_value.value
{
Some(PathBuf::from(value.value()))
} else {
None
}
}
_ => None,
}
})
}
fn module_dir_for_resolved_module(file_path: &Path) -> PathBuf {
let parent = file_path.parent().unwrap_or_else(|| Path::new(""));
if file_path.file_name() == Some(OsStr::new("mod.rs")) {
parent.to_path_buf()
} else {
parent.join(
file_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default(),
)
}
}
pub(crate) fn join_module_path(prefix: &str, rest: &str) -> String {
if prefix.is_empty() {
rest.to_string()
} else if rest.is_empty() {
prefix.to_string()
} else {
format!("{prefix}::{rest}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rs_files_with_hidden_parent_directory() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let hidden_parent = temp.path().join(".hidden-parent");
let project_dir = hidden_parent.join("myproject").join("src");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
fs::write(project_dir.join("main.rs"), "fn main() {}").unwrap();
let files: Vec<_> = rs_files(&project_dir).collect();
assert_eq!(
files.len(),
2,
"Should find 2 .rs files in hidden parent path"
);
let file_names: Vec<_> = files
.iter()
.filter_map(|p| p.file_name())
.filter_map(|n| n.to_str())
.collect();
assert!(file_names.contains(&"lib.rs"));
assert!(file_names.contains(&"main.rs"));
}
#[test]
fn test_rs_files_excludes_hidden_dirs_in_project() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let project_dir = temp.path().join("myproject").join("src");
let hidden_dir = project_dir.join(".hidden");
fs::create_dir_all(&hidden_dir).unwrap();
fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
fs::write(hidden_dir.join("secret.rs"), "fn secret() {}").unwrap();
let files: Vec<_> = rs_files(&project_dir).collect();
assert_eq!(
files.len(),
1,
"Should find only 1 .rs file (excluding .hidden/)"
);
let file_names: Vec<_> = files
.iter()
.filter_map(|p| p.file_name())
.filter_map(|n| n.to_str())
.collect();
assert!(file_names.contains(&"lib.rs"));
assert!(!file_names.contains(&"secret.rs"));
}
#[test]
fn test_rs_files_excludes_target_directory() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let project_dir = temp.path().join("myproject");
let src_dir = project_dir.join("src");
let target_dir = project_dir.join("target").join("debug");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir_all(&target_dir).unwrap();
fs::write(src_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
fs::write(target_dir.join("generated.rs"), "// generated").unwrap();
let files: Vec<_> = rs_files(&project_dir).collect();
assert_eq!(
files.len(),
1,
"Should find only 1 .rs file (excluding target/)"
);
let file_names: Vec<_> = files
.iter()
.filter_map(|p| p.file_name())
.filter_map(|n| n.to_str())
.collect();
assert!(file_names.contains(&"lib.rs"));
assert!(!file_names.contains(&"generated.rs"));
}
#[test]
fn resolve_external_module_file_prefers_path_attribute() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let module_dir = temp.path().join("src");
fs::create_dir_all(&module_dir).unwrap();
fs::write(module_dir.join("custom.rs"), "pub fn custom() {}").unwrap();
fs::write(module_dir.join("name.rs"), "pub fn flat() {}").unwrap();
fs::create_dir_all(module_dir.join("name")).unwrap();
fs::write(module_dir.join("name/mod.rs"), "pub fn nested() {}").unwrap();
let item_mod: ItemMod = syn::parse_quote!(
#[path = "custom.rs"]
mod name;
);
let resolved = resolve_external_module_file(
&module_dir,
&item_mod,
&canonical_file_key(temp.path()),
&canonical_file_key(&temp.path().join("Cargo.toml")),
);
assert_eq!(
resolved,
ModuleFileResolution::Resolved(canonical_file_key(&module_dir.join("custom.rs")))
);
}
#[test]
fn resolve_external_module_file_uses_flat_before_nested() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let module_dir = temp.path().join("src");
fs::create_dir_all(module_dir.join("name")).unwrap();
fs::write(module_dir.join("name.rs"), "pub fn flat() {}").unwrap();
fs::write(module_dir.join("name/mod.rs"), "pub fn nested() {}").unwrap();
let item_mod: ItemMod = syn::parse_quote!(
mod name;
);
let resolved = resolve_external_module_file(
&module_dir,
&item_mod,
&canonical_file_key(temp.path()),
&canonical_file_key(&temp.path().join("Cargo.toml")),
);
assert_eq!(
resolved,
ModuleFileResolution::Resolved(canonical_file_key(&module_dir.join("name.rs")))
);
}
#[test]
fn resolve_external_module_file_rejects_outside_workspace() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let workspace = temp.path().join("workspace");
let module_dir = workspace.join("src");
fs::create_dir_all(&module_dir).unwrap();
fs::write(temp.path().join("outside.rs"), "pub fn outside() {}").unwrap();
let item_mod: ItemMod = syn::parse_quote!(
#[path = "../../outside.rs"]
mod outside;
);
let resolved = resolve_external_module_file(
&module_dir,
&item_mod,
&canonical_file_key(&workspace),
&canonical_file_key(&workspace.join("Cargo.toml")),
);
assert_eq!(resolved, ModuleFileResolution::BoundarySkipped);
}
#[test]
fn resolve_external_module_file_rejects_other_package() {
use std::fs;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let workspace = temp.path().join("workspace");
let current = workspace.join("a");
let other = workspace.join("b");
let module_dir = current.join("src");
fs::create_dir_all(&module_dir).unwrap();
fs::create_dir_all(other.join("src")).unwrap();
fs::write(current.join("Cargo.toml"), "[package]\nname = \"a\"\n").unwrap();
fs::write(other.join("Cargo.toml"), "[package]\nname = \"b\"\n").unwrap();
fs::write(other.join("src/shared.rs"), "pub fn shared() {}").unwrap();
let item_mod: ItemMod = syn::parse_quote!(
#[path = "../../b/src/shared.rs"]
mod shared;
);
let resolved = resolve_external_module_file(
&module_dir,
&item_mod,
&canonical_file_key(&workspace),
&canonical_file_key(¤t.join("Cargo.toml")),
);
assert_eq!(resolved, ModuleFileResolution::BoundarySkipped);
}
#[test]
fn test_file_path_to_module_path_nested() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/level/enemy/spawner.rs");
assert_eq!(
file_path_to_module_path(file_path, src_root),
"level::enemy::spawner"
);
}
#[test]
fn test_file_path_to_module_path_lib() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/lib.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "");
}
#[test]
fn test_file_path_to_module_path_main() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/main.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "");
}
#[test]
fn test_file_path_to_module_path_mod() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/level/mod.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "level");
}
#[test]
fn test_file_path_to_module_path_deeply_nested_mod() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/a/b/c/mod.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "a::b::c");
}
#[test]
fn test_file_path_to_module_path_simple() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/utils.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "utils");
}
#[test]
fn test_file_path_to_module_path_two_levels() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/foo/bar.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "foo::bar");
}
#[test]
fn test_file_path_to_module_path_bin() {
let src_root = Path::new("/project/src");
let file_path = Path::new("/project/src/bin/cli.rs");
assert_eq!(file_path_to_module_path(file_path, src_root), "bin::cli");
}
#[test]
fn test_file_path_to_module_path_mismatched_root() {
let src_root = Path::new("/other/src");
let file_path = Path::new("/project/src/utils.rs");
let result = file_path_to_module_path(file_path, src_root);
assert!(result.contains("utils"));
}
}