use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::deep::{DeepContext, DeepError, FileId, referencing_files};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::functions::walk_functions;
use crate::ingest::Workspace;
use crate::rules::boundaries::{
BoundaryConfig, ModuleBoundaryRule, module_path_for_file, module_path_under,
};
pub const MODULE_BOUNDARY_VIOLATION_DEEP_RULE: &str = "module-boundary-violation-deep";
pub const MODULE_BOUNDARY_VIOLATION_DEEP_RULE_REVISION: u32 = 1;
#[derive(Debug)]
pub enum BoundaryDeepError {
Deep(DeepError),
Io(PathBuf, std::io::Error),
Parse(PathBuf, syn::Error),
}
impl std::fmt::Display for BoundaryDeepError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deep(err) => write!(f, "{err}"),
Self::Io(path, err) => crate::rules::dead_code::fmt_io_error(f, path, err),
Self::Parse(path, err) => crate::rules::dead_code::fmt_parse_error(f, path, err),
}
}
}
impl std::error::Error for BoundaryDeepError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Deep(err) => Some(err),
Self::Io(_, err) => Some(err),
Self::Parse(_, err) => Some(err),
}
}
}
#[derive(Debug, Default)]
pub struct WorkspaceModuleBoundariesDeep {
pub findings: Vec<Finding>,
pub errors: Vec<BoundaryDeepError>,
}
pub fn analyze_workspace(
workspace: &Workspace,
config: &BoundaryConfig,
) -> Result<WorkspaceModuleBoundariesDeep, BoundaryDeepError> {
let mut report = WorkspaceModuleBoundariesDeep::default();
if config.module_boundaries.is_empty() {
return Ok(report);
}
let ctx = DeepContext::load(&workspace.root).map_err(BoundaryDeepError::Deep)?;
let analysis = ctx.analysis();
for rule in &config.module_boundaries {
let Some(krate) = workspace.crates.iter().find(|k| k.name == rule.krate) else {
continue;
};
let mut file_path_by_id: HashMap<FileId, PathBuf> = HashMap::new();
for file in &krate.source_files {
if let Some(file_id) = ctx.file_id(&file.path).map_err(BoundaryDeepError::Deep)? {
file_path_by_id.insert(file_id, file.path.clone());
}
}
for (file, module_path) in crate::rules::boundaries::files_with_module_path(krate) {
let in_forbidden_scope = rule
.forbidden
.iter()
.any(|forbidden| module_path_under(&module_path, forbidden));
if !in_forbidden_scope {
continue;
}
let Some(file_id) = ctx.file_id(&file.path).map_err(BoundaryDeepError::Deep)? else {
continue;
};
let ast = match crate::functions::read_and_parse_source(
&file.path,
|err| BoundaryDeepError::Io(file.path.clone(), err),
|err| BoundaryDeepError::Parse(file.path.clone(), err),
) {
Ok((_, ast)) => ast,
Err(err) => {
report.errors.push(err);
continue;
}
};
let mut deep_error = None;
walk_functions(&ast, |site| {
if deep_error.is_some() {
return;
}
let (offset, line) = crate::rules::dead_code::offset_and_line(site.ident_span);
let position = ra_ap_ide::FilePosition {
file_id,
offset: offset.into(),
};
let referencing = match referencing_files(&analysis, position, true) {
Ok(referencing) => referencing,
Err(err) => {
deep_error = Some(err);
return;
}
};
let mut witnesses: Vec<&PathBuf> = referencing
.iter()
.filter_map(|referencing_id| file_path_by_id.get(referencing_id))
.filter(|referencing_path| {
module_path_for_file(&krate.root, referencing_path).is_some_and(
|referencing_module| module_path_under(&referencing_module, &rule.from),
)
})
.collect();
if witnesses.is_empty() {
return;
}
witnesses.sort();
report.findings.push(module_boundary_violation_deep_finding(
rule,
&file.path,
&site.qualified_name,
line,
witnesses[0],
));
});
if let Some(err) = deep_error {
report.errors.push(BoundaryDeepError::Deep(err));
}
}
}
report
.findings
.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
Ok(report)
}
fn module_boundary_violation_deep_finding(
rule: &ModuleBoundaryRule,
file: &Path,
qualified_name: &str,
line: usize,
witness_file: &Path,
) -> Finding {
Finding::new(
format!(
"{MODULE_BOUNDARY_VIOLATION_DEEP_RULE}:{}:{qualified_name}",
file.display()
),
MODULE_BOUNDARY_VIOLATION_DEEP_RULE,
Severity::Warn,
Location {
file: file.to_path_buf(),
line: OneBasedLine::new(line).expect("proc-macro2 span lines are 1-based"),
item_path: qualified_name.to_string(),
},
EvidenceClass::BoundedSemantic,
Origin::Code,
Some(serde_json::json!({
"rule": rule.name,
"from": rule.from,
"witness_file": witness_file.display().to_string(),
"basis": "deep_symbol_reference",
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
fn write_crate(dir: &TempDir) {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
}
fn rule(name: &str, from: &str, forbidden: &[&str]) -> ModuleBoundaryRule {
ModuleBoundaryRule {
name: name.to_string(),
krate: "fixture".to_string(),
from: from.to_string(),
forbidden: forbidden.iter().map(|s| s.to_string()).collect(),
reach: None,
}
}
#[test]
fn catches_a_reference_through_a_re_export_the_text_scan_would_miss() {
let dir = TempDir::new("boundaries-deep-reexport");
write_crate(&dir);
std::fs::write(
dir.join("src/lib.rs"),
"pub use forbidden::secret;\npub mod forbidden;\npub mod from_mod;\n",
)
.unwrap();
std::fs::write(dir.join("src/forbidden.rs"), "pub fn secret() {}\n").unwrap();
std::fs::write(
dir.join("src/from_mod.rs"),
"pub fn caller() {\n crate::secret();\n}\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![rule(
"no-forbidden-from-from-mod",
"from_mod",
&["forbidden"],
)],
..Default::default()
};
let report = analyze_workspace(&workspace, &config).unwrap();
assert!(
report.errors.is_empty(),
"unexpected errors: {:?}",
report.errors
);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, MODULE_BOUNDARY_VIOLATION_DEEP_RULE);
assert_eq!(report.findings[0].location.item_path, "secret");
let evidence = report.findings[0].evidence.as_ref().unwrap();
assert!(
evidence["witness_file"]
.as_str()
.unwrap()
.ends_with("from_mod.rs")
);
}
#[test]
fn does_not_fire_when_from_never_references_forbidden() {
let dir = TempDir::new("boundaries-deep-no-violation");
write_crate(&dir);
std::fs::write(
dir.join("src/lib.rs"),
"pub mod forbidden;\npub mod from_mod;\n",
)
.unwrap();
std::fs::write(dir.join("src/forbidden.rs"), "pub fn secret() {}\n").unwrap();
std::fs::write(dir.join("src/from_mod.rs"), "pub fn caller() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![rule(
"no-forbidden-from-from-mod",
"from_mod",
&["forbidden"],
)],
..Default::default()
};
let report = analyze_workspace(&workspace, &config).unwrap();
assert!(report.findings.is_empty());
}
#[test]
fn performs_no_analysis_without_any_module_boundary_rules() {
let dir = TempDir::new("boundaries-deep-no-rules");
write_crate(&dir);
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig::default();
let report = analyze_workspace(&workspace, &config).unwrap();
assert!(report.findings.is_empty());
assert!(report.errors.is_empty());
}
}