use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use clap::{CommandFactory, Parser};
use regex::Regex;
use crate::deps::{grammar, EdgeKind, Graph, Package};
use crate::pattern;
use crate::rules::ProbeOutcome;
use crate::walk::{self, EntryType};
pub fn module_name(rel: &Path) -> String {
let mut segs: Vec<String> = rel
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect();
if let Some(file) = segs.pop() {
let stem = Path::new(&file)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or(file);
if stem != "mod" && stem != "lib" && stem != "main" {
segs.push(stem);
}
}
if segs.is_empty() {
"crate".to_string()
} else {
segs.join("::")
}
}
pub fn build_graph(files: &[(String, String)]) -> Graph {
let modules: HashSet<&str> = files.iter().map(|(n, _)| n.as_str()).collect();
let mut packages = HashMap::new();
let mut edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>> = HashMap::new();
let mut members: Vec<String> = Vec::new();
for (name, content) in files {
packages.insert(
name.clone(),
Package { name: name.clone(), version: String::new() },
);
members.push(name.clone());
let current = name_segs(name);
let mut targets: BTreeSet<String> = BTreeSet::new();
for raw in use_targets(content) {
if let Some(t) = resolve(&raw, ¤t, &modules)
&& t != *name
{
targets.insert(t);
}
}
edges.insert(
name.clone(),
targets.into_iter().map(|t| (t, vec![EdgeKind::Normal])).collect(),
);
}
members.sort();
members.dedup();
Graph { packages, edges, members }
}
pub fn use_targets(content: &str) -> Vec<String> {
let mut out = Vec::new();
for stmt in use_statements(content) {
for leaf in expand_braces(&strip_aliases(&stmt)) {
let leaf = leaf.trim();
let head = leaf.split("::").next().unwrap_or("");
if matches!(head, "crate" | "self" | "super") {
out.push(leaf.to_string());
}
}
}
out
}
fn resolve(raw: &str, current: &[String], modules: &HashSet<&str>) -> Option<String> {
let parts: Vec<&str> = raw.split("::").map(str::trim).filter(|s| !s.is_empty()).collect();
let mut abs: Vec<String> = Vec::new();
let mut i = 0;
match *parts.first()? {
"crate" => i = 1,
"self" => {
abs = current.to_vec();
i = 1;
}
"super" => {
abs = current.to_vec();
while parts.get(i) == Some(&"super") {
abs.pop();
i += 1;
}
}
_ => return None, }
for p in &parts[i..] {
if *p == "self" || *p == "*" {
continue; }
abs.push((*p).to_string());
}
loop {
let name = if abs.is_empty() { "crate".to_string() } else { abs.join("::") };
if modules.contains(name.as_str()) {
return Some(name);
}
abs.pop()?;
}
}
fn name_segs(name: &str) -> Vec<String> {
if name == "crate" {
Vec::new()
} else {
name.split("::").map(String::from).collect()
}
}
fn use_statements(content: &str) -> Vec<String> {
let mut stmts = Vec::new();
let mut lines = content.lines();
while let Some(line) = lines.next() {
let Some(rest) = strip_vis_use(line) else {
continue;
};
let mut body = rest.to_string();
loop {
if let Some(idx) = body.find(';') {
body.truncate(idx);
stmts.push(body);
break;
}
match lines.next() {
Some(next) => {
body.push(' ');
body.push_str(next.trim());
}
None => {
stmts.push(body);
break;
}
}
}
}
stmts
}
fn strip_vis_use(line: &str) -> Option<&str> {
let t = line.trim_start();
let after_vis = if let Some(r) = t.strip_prefix("pub") {
let r = r.trim_start();
let r = if r.starts_with('(') {
r.find(')').map(|i| &r[i + 1..]).unwrap_or(r)
} else {
r
};
r.trim_start()
} else {
t
};
after_vis
.strip_prefix("use")
.filter(|r| r.starts_with(char::is_whitespace))
.map(str::trim_start)
}
fn strip_aliases(s: &str) -> std::borrow::Cow<'_, str> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*").unwrap());
re.replace_all(s, "")
}
fn expand_braces(s: &str) -> Vec<String> {
let s = s.trim();
match s.find('{') {
None => {
if s.is_empty() {
vec![]
} else {
vec![s.to_string()]
}
}
Some(open) => {
let Some(close) = matching_brace(s.as_bytes(), open) else {
return vec![s.to_string()]; };
let prefix = &s[..open];
let inner = &s[open + 1..close];
let suffix = &s[close + 1..];
let mut out = Vec::new();
for part in split_top_commas(inner) {
let part = part.trim();
if part.is_empty() {
continue;
}
out.extend(expand_braces(&format!("{prefix}{part}{suffix}")));
}
out
}
}
}
fn matching_brace(bytes: &[u8], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (i, &b) in bytes.iter().enumerate().skip(open) {
match b {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
fn split_top_commas(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut start = 0;
for (i, c) in s.char_indices() {
match c {
'{' => depth += 1,
'}' => depth -= 1,
',' if depth == 0 => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
#[derive(Parser, Debug)]
#[command(no_binary_name = true, disable_help_flag = true)]
struct ModsCheck {
#[arg(long, default_value = "src")]
base: String,
#[arg(long)]
name: Option<String>,
#[arg(long, value_delimiter = ',')]
ext: Vec<String>,
#[arg(long)]
hidden: bool,
#[arg(long)]
follow: bool,
#[arg(long, value_name = "A=>B")]
forbid: Vec<String>,
#[arg(long)]
acyclic: bool,
#[arg(long, value_name = "L0,L1,...", value_delimiter = ',')]
layers: Vec<String>,
#[arg(long)]
layers_closed: bool,
}
pub fn check_grammar() -> crate::deps::Grammar {
grammar(ModsCheck::command())
}
pub fn check(args: &[String], root: &Path, timeout: Option<Duration>) -> (ProbeOutcome, String, String) {
let started = Instant::now();
let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
let cli = match ModsCheck::try_parse_from(args.iter().map(String::as_str)) {
Ok(c) => c,
Err(e) => {
let valid = check_grammar().flags.iter().map(|s| format!("--{}", s.name)).collect::<Vec<_>>().join(" ");
return broken(format!(
"mods: {} (valid flags: {valid})",
e.to_string().lines().next().unwrap_or("bad arguments")
));
}
};
if cli.forbid.is_empty() && !cli.acyclic && cli.layers.is_empty() {
return broken("mods: nothing to assert (--forbid/--acyclic/--layers)".to_string());
}
if cli.layers_closed && cli.layers.is_empty() {
return broken("mods: --layers-closed requires --layers".to_string());
}
let forbids: Vec<(String, String)> = match cli
.forbid
.iter()
.map(|spec| {
spec.split_once("=>")
.map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
.filter(|(a, b)| !a.is_empty() && !b.is_empty())
.ok_or_else(|| format!("mods: --forbid needs 'A=>B', got '{spec}'"))
})
.collect()
{
Ok(f) => f,
Err(e) => return broken(e),
};
let mut name_spec = cli.name.clone().unwrap_or_default();
let exts: Vec<String> = if cli.ext.is_empty() { vec!["rs".to_string()] } else { cli.ext.clone() };
for e in &exts {
let e = e.trim().trim_start_matches('.');
if e.is_empty() {
continue;
}
if !name_spec.is_empty() {
name_spec.push('|');
}
name_spec.push_str(&format!("*.{e}"));
}
let names = match pattern::compile_name_set(&name_spec) {
Ok(n) => n,
Err(e) => return broken(format!("mods: invalid --name/--ext: {e}")),
};
let base = root.join(&cli.base);
let selector = walk::Selector {
base: base.clone(),
names: Some(names),
types: vec![EntryType::F],
size: None,
hidden: cli.hidden,
follow: cli.follow,
no_ignore: false,
};
let mut files: Vec<(String, String)> = Vec::new();
for entry in selector.walk() {
if let Some(limit) = timeout
&& started.elapsed() >= limit
{
return broken(format!("mods: timed out after {:.1}s", limit.as_secs_f64()));
}
let entry = match entry {
Ok(e) => e,
Err(e) => return broken(format!("mods: {e}")),
};
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
let rel = path.strip_prefix(&base).unwrap_or(path);
let Ok(text) = std::fs::read_to_string(path) else {
continue; };
files.push((module_name(rel), text));
}
if files.is_empty() {
return broken(format!("mods: no source files under {}", base.display()));
}
let graph = build_graph(&files);
let allowed: HashSet<EdgeKind> = [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect();
let mut violations: Vec<crate::deps::Violation> = Vec::new();
for (from, to) in &forbids {
match crate::deps::forbid_path(&graph, from, to, &allowed) {
Ok(v) => violations.extend(v),
Err(e) => return broken(format!("mods: {e}")),
}
}
if cli.acyclic {
violations.extend(crate::deps::cycles(&graph, &allowed, false));
}
if !cli.layers.is_empty() {
let compiled = match cli.layers.iter().map(|p| pattern::compile_anchored(p)).collect::<Result<Vec<_>, _>>() {
Ok(c) => c,
Err(e) => return broken(format!("mods: --layers invalid pattern: {e}")),
};
let (layers, unassigned) =
match crate::deps::assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
Ok(r) => r,
Err(e) => return broken(format!("mods: --layers: {e}")),
};
violations.extend(crate::deps::layer_violations(&graph, &layers, &allowed));
if cli.layers_closed {
violations.extend(unassigned.into_iter().map(|name| crate::deps::Violation {
check: "layers-closed".to_string(),
subject: name,
evidence: "matches no layer".to_string(),
}));
}
}
crate::deps::report_outcome("mods", violations)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deps::{self, EdgeKind};
fn all_edges() -> HashSet<EdgeKind> {
[EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect()
}
#[test]
fn use_targets_handles_the_common_forms() {
let src = r#"
// a leading comment with the word use in it
use std::collections::HashMap; // external: dropped
use crate::domain::Entity;
pub use crate::infra::{Db, cache::Lru}; // re-export + nested brace
use self::helpers::go as g; // self + alias
use super::sibling::Thing;
use crate::a::{self, b}; // self segment (folds in resolve)
fn body() {
use crate::late::Local; // own-line local import: counts
}
"#;
let mut t = use_targets(src);
t.sort();
assert_eq!(
t,
vec![
"crate::a::b",
"crate::a::self",
"crate::domain::Entity",
"crate::infra::Db",
"crate::infra::cache::Lru",
"crate::late::Local",
"self::helpers::go",
"super::sibling::Thing",
]
);
}
#[test]
fn use_targets_joins_multiline_groups() {
let src = "use crate::a::{\n b,\n c::d,\n};\n";
let mut t = use_targets(src);
t.sort();
assert_eq!(t, vec!["crate::a::b", "crate::a::c::d"]);
}
#[test]
fn resolve_picks_the_longest_known_module() {
let modules: HashSet<&str> = ["crate", "a", "a::b", "domain"].into_iter().collect();
assert_eq!(resolve("crate::a::b::Item", &[], &modules).as_deref(), Some("a::b"));
assert_eq!(resolve("crate::a::Item", &[], &modules).as_deref(), Some("a"));
let cur = name_segs("a::b");
assert_eq!(resolve("super::Item", &cur, &modules).as_deref(), Some("a"));
assert_eq!(resolve("self::Item", &cur, &modules).as_deref(), Some("a::b"));
assert_eq!(resolve("crate::TopItem", &[], &modules).as_deref(), Some("crate"));
assert_eq!(resolve("serde::Deserialize", &[], &modules), None);
}
fn sample_crate() -> Vec<(String, String)> {
vec![
("crate".into(), "mod domain;\nmod infra;\nuse crate::domain::Entity;\n".into()),
("domain".into(), "use crate::infra::Db;\npub struct Entity;\n".into()),
("infra".into(), "pub struct Db;\n".into()),
]
}
#[test]
fn build_graph_edges_and_forbid() {
let g = build_graph(&sample_crate());
let v = deps::forbid_path(&g, "domain", "infra", &all_edges()).unwrap().unwrap();
assert_eq!(v.subject, "domain=>infra");
assert_eq!(v.evidence, "domain -> infra");
assert!(deps::forbid_path(&g, "infra", "domain", &all_edges()).unwrap().is_none());
}
#[test]
fn build_graph_layers_flag_an_upward_module_edge() {
let g = build_graph(&sample_crate());
let labels = vec!["infra".to_string(), "domain".to_string()];
let (layers, _) = deps::assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
let viol = deps::layer_violations(&g, &layers, &all_edges());
assert_eq!(viol.len(), 1);
assert_eq!(viol[0].subject, "domain => infra");
assert_eq!(viol[0].evidence, "domain -> infra");
}
#[test]
fn build_graph_detects_a_module_cycle() {
let files = vec![
("crate".into(), "mod a;\nmod b;\n".to_string()),
("a".into(), "use crate::b::Thing;\n".to_string()),
("b".into(), "use crate::a::Other;\n".to_string()),
];
let g = build_graph(&files);
let cycles = deps::cycles(&g, &all_edges(), false);
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].subject, "a, b");
assert_eq!(cycles[0].evidence, "a -> b -> a");
}
}