use std::env;
use std::ffi::OsString;
use std::fs;
use std::hash::Hash;
use std::hash::Hasher;
use std::io::BufRead;
use std::io::BufReader;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::ExitCode;
use std::process::Stdio;
use anyhow::Context;
use anyhow::Result;
use quote::ToTokens;
use rustc_driver::Callbacks;
use rustc_driver::Compilation;
use rustc_hir::ForeignItem;
use rustc_hir::ForeignItemKind;
use rustc_hir::ImplItem;
use rustc_hir::ImplItemKind;
use rustc_hir::Item;
use rustc_hir::ItemKind;
use rustc_middle::middle::privacy::Level;
use rustc_middle::ty::TyCtxt;
use rustc_span::FileName;
use rustc_span::Span;
use rustc_span::def_id::CRATE_DEF_ID;
use rustc_span::def_id::LocalDefId;
use serde::Deserialize;
use serde::Serialize;
use syn::ItemUse;
use syn::UseTree;
use syn::visit::Visit;
use super::config::DiagnosticCode;
use super::config::LoadedConfig;
use super::config::VisibilityConfig;
use super::constants::EXIT_CODE_ERROR;
use super::diagnostics::CompilerWarningFacts;
use super::diagnostics::Finding;
use super::diagnostics::PubUseFixFact;
use super::diagnostics::PubUseFixFacts;
use super::diagnostics::Report;
use super::diagnostics::ReportFacts;
use super::diagnostics::ReportSummary;
use super::diagnostics::Severity;
use super::fix_support::FixSupport;
use super::module_paths;
use super::outcome::AnalysisFailure;
use super::outcome::CompilerFailureCause;
use super::outcome::MendFailure;
use super::selection::SelectedPackage;
use super::selection::Selection;
use super::selection::SelectionScope;
const DRIVER_ENV: &str = "MEND_DRIVER";
const CONFIG_ROOT_ENV: &str = "MEND_CONFIG_ROOT";
const CONFIG_JSON_ENV: &str = "MEND_CONFIG_JSON";
const CONFIG_FINGERPRINT_ENV: &str = "MEND_CONFIG_FINGERPRINT";
const FINDINGS_DIR_ENV: &str = "MEND_FINDINGS_DIR";
const PACKAGE_ROOT_ENV: &str = "CARGO_MANIFEST_DIR";
const FINDINGS_SCHEMA_VERSION: u32 = 12;
fn current_analysis_fingerprint() -> String {
let version = env!("CARGO_PKG_VERSION");
let git_hash = option_env!("MEND_GIT_HASH").unwrap_or("nogit");
let build_id = option_env!("MEND_BUILD_ID").unwrap_or("nobuild");
format!("{version}+{git_hash}+{build_id}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildOutputMode {
Full,
SuppressUnusedImportWarnings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiagnosticBlockKind {
SuppressedUnusedImport,
ForwardedDiagnostic,
}
#[derive(Debug, Clone, Copy)]
struct CommandOutcome {
status: std::process::ExitStatus,
saw_unused_import_warning: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredReport {
version: u32,
#[serde(default)]
analysis_fingerprint: String,
package_root: String,
config_fingerprint: String,
findings: Vec<StoredFinding>,
#[serde(default)]
pub_use_fix_facts: Vec<StoredPubUseFixFact>,
#[serde(default)]
saw_unused_import_warnings: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredFinding {
severity: Severity,
code: DiagnosticCode,
path: String,
line: usize,
column: usize,
highlight_len: usize,
source_line: String,
item: Option<String>,
message: String,
suggestion: Option<String>,
#[serde(default)]
fix_support: FixSupport,
#[serde(default)]
related: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredPubUseFixFact {
child_path: String,
child_line: usize,
child_item_name: String,
parent_path: String,
parent_line: usize,
child_module: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CrateKind {
Binary,
Library,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModuleLocation {
CrateRoot,
TopLevelPrivateModule,
NestedModule,
}
#[derive(Debug, Clone)]
struct DriverSettings {
config_root: PathBuf,
config: VisibilityConfig,
config_fingerprint: String,
analysis_fingerprint: String,
findings_dir: PathBuf,
package_root: PathBuf,
}
impl DriverSettings {
fn from_env() -> Result<Self> {
let config_root = PathBuf::from(
env::var_os(CONFIG_ROOT_ENV).context("missing MEND_CONFIG_ROOT for compiler driver")?,
);
let config = serde_json::from_str(
&env::var(CONFIG_JSON_ENV).context("missing MEND_CONFIG_JSON for compiler driver")?,
)
.context("failed to parse MEND_CONFIG_JSON")?;
let config_fingerprint =
env::var(CONFIG_FINGERPRINT_ENV).context("missing MEND_CONFIG_FINGERPRINT")?;
let findings_dir = PathBuf::from(
env::var_os(FINDINGS_DIR_ENV)
.context("missing MEND_FINDINGS_DIR for compiler driver")?,
);
let package_root = PathBuf::from(
env::var_os(PACKAGE_ROOT_ENV)
.context("missing CARGO_MANIFEST_DIR for compiler driver")?,
);
Ok(Self {
config_root,
config,
config_fingerprint,
analysis_fingerprint: current_analysis_fingerprint(),
findings_dir,
package_root,
})
}
}
#[derive(Debug)]
struct AnalysisCallbacks {
settings: DriverSettings,
error: Option<anyhow::Error>,
}
impl AnalysisCallbacks {
const fn new(settings: DriverSettings) -> Self {
Self {
settings,
error: None,
}
}
}
impl Callbacks for AnalysisCallbacks {
fn after_analysis(
&mut self,
_compiler: &rustc_interface::interface::Compiler,
tcx: TyCtxt<'_>,
) -> Compilation {
match collect_and_store_findings(tcx, &self.settings) {
Ok(true | false) => Compilation::Continue,
Err(err) => {
self.error = Some(err);
Compilation::Stop
},
}
}
}
pub fn run_selection(
selection: &Selection,
loaded_config: &LoadedConfig,
output_mode: BuildOutputMode,
) -> Result<Report, MendFailure> {
let findings_dir = selection.target_directory.join("mend-findings");
fs::create_dir_all(&findings_dir).with_context(|| {
format!(
"failed to create persistent findings directory {}",
findings_dir.display()
)
})?;
let mut command_outcome = run_cargo_check(selection, loaded_config, &findings_dir, output_mode)
.map_err(|err| {
MendFailure::Analysis(AnalysisFailure {
cause: CompilerFailureCause::DriverSetup(err),
})
})?;
if !command_outcome.status.success() {
return Err(MendFailure::Analysis(AnalysisFailure {
cause: CompilerFailureCause::CargoCheck,
}));
}
let missing_packages = selection
.packages
.iter()
.filter(|package| {
!cache_is_current_for(
&findings_dir,
&package.root,
&package.source_root,
loaded_config,
)
})
.collect::<Vec<_>>();
if !missing_packages.is_empty() {
for package in missing_packages {
let status = run_cargo_rustc_for_package(
package,
loaded_config,
&findings_dir,
&selection.target_directory,
output_mode,
)
.map_err(|err| {
MendFailure::Analysis(AnalysisFailure {
cause: CompilerFailureCause::DriverSetup(err),
})
})?;
command_outcome.saw_unused_import_warning |= status.saw_unused_import_warning;
if !status.status.success() {
return Err(MendFailure::Analysis(AnalysisFailure {
cause: CompilerFailureCause::CargoRustcRefresh {
package: package.name.clone(),
},
}));
}
}
}
let report = load_report(&findings_dir, selection).map_err(|err| {
MendFailure::Analysis(AnalysisFailure {
cause: CompilerFailureCause::DriverExecution(err),
})
})?;
let mut report = report;
report.facts.compiler_warnings = if command_outcome.saw_unused_import_warning {
CompilerWarningFacts::UnusedImportWarnings
} else {
CompilerWarningFacts::None
};
Ok(report)
}
pub fn driver_main() -> ExitCode {
match driver_main_impl() {
Ok(code) => code,
Err(err) => {
eprintln!("mend: {err:#}");
ExitCode::from(1)
},
}
}
fn driver_main_impl() -> Result<ExitCode> {
let wrapper_args: Vec<OsString> = env::args_os().collect();
if wrapper_args.len() < 2 {
anyhow::bail!("compiler driver expected rustc wrapper arguments");
}
let Ok(settings) = DriverSettings::from_env() else {
return passthrough_to_rustc(&wrapper_args);
};
let rustc_args: Vec<String> = std::iter::once("rustc".to_string())
.chain(
wrapper_args
.into_iter()
.skip(2)
.map(|arg| arg.to_string_lossy().into_owned()),
)
.collect();
let mut callbacks = AnalysisCallbacks::new(settings);
let compiler_exit_code = rustc_driver::catch_with_exit_code(|| {
rustc_driver::run_compiler(&rustc_args, &mut callbacks);
})
.into_exit_code();
let exit_code = callbacks.error.map_or(compiler_exit_code, |err| {
eprintln!("mend: {err:#}");
ExitCode::FAILURE
});
Ok(exit_code)
}
fn passthrough_to_rustc(wrapper_args: &[OsString]) -> Result<ExitCode> {
let rustc = wrapper_args
.get(1)
.context("compiler driver expected rustc path in wrapper arguments")?;
let status = Command::new(rustc)
.args(wrapper_args.iter().skip(2))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("failed to invoke rustc passthrough from mend wrapper")?;
Ok(exit_code_from_i32(status.code().unwrap_or(1)))
}
fn toolchain_override() -> Option<ToolchainOverride> {
let build_sysroot = option_env!("MEND_BUILD_SYSROOT")?;
let output = Command::new("rustc")
.args(["--print", "sysroot"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let current_sysroot = String::from_utf8(output.stdout).ok()?;
if build_sysroot == current_sysroot.trim() {
return None;
}
let build_path = Path::new(build_sysroot);
let mut components = build_path.components();
let toolchain_name = loop {
match components.next() {
Some(component) if component.as_os_str() == "toolchains" => {
break components.next()?.as_os_str().to_str()?.to_string();
},
Some(_) => {},
None => return None,
}
};
Some(ToolchainOverride { toolchain_name })
}
struct ToolchainOverride {
toolchain_name: String,
}
impl ToolchainOverride {
fn apply(&self, command: &mut Command, target_directory: &Path) {
command
.env("RUSTUP_TOOLCHAIN", &self.toolchain_name)
.env("CARGO_TARGET_DIR", target_directory.join("mend"));
}
}
fn run_cargo_check(
selection: &Selection,
loaded_config: &LoadedConfig,
findings_dir: &Path,
output_mode: BuildOutputMode,
) -> Result<CommandOutcome> {
let current_exe = env::current_exe().context("failed to determine current executable path")?;
let mut command = Command::new("cargo");
command.arg("check");
match selection.scope {
SelectionScope::Workspace => {
command.arg("--workspace");
},
SelectionScope::SinglePackage => {
command
.arg("--manifest-path")
.arg(selection.manifest_path.as_os_str());
},
}
let tc_override = toolchain_override();
command
.env("RUSTC_WORKSPACE_WRAPPER", ¤t_exe)
.env(DRIVER_ENV, "1")
.env(CONFIG_ROOT_ENV, &loaded_config.root)
.env(
CONFIG_JSON_ENV,
serde_json::to_string(&loaded_config.config)
.context("failed to serialize mend config for compiler driver")?,
)
.env(CONFIG_FINGERPRINT_ENV, &loaded_config.fingerprint)
.env(FINDINGS_DIR_ENV, findings_dir)
.stdin(Stdio::inherit());
if let Some(tc) = &tc_override {
tc.apply(&mut command, &selection.target_directory);
}
run_cargo_command(&mut command, output_mode).context("failed to run cargo check for mend")
}
fn run_cargo_rustc_for_package(
package: &SelectedPackage,
loaded_config: &LoadedConfig,
findings_dir: &Path,
target_directory: &Path,
output_mode: BuildOutputMode,
) -> Result<CommandOutcome> {
let current_exe = env::current_exe().context("failed to determine current executable path")?;
let mut command = Command::new("cargo");
command.arg("rustc");
command
.arg("--manifest-path")
.arg(package.manifest_path.as_os_str());
for arg in package.target.cargo_args() {
command.arg(arg);
}
for arg in refresh_rustc_args() {
command.arg(arg);
}
let tc_override = toolchain_override();
command
.env("RUSTC_WORKSPACE_WRAPPER", ¤t_exe)
.env(DRIVER_ENV, "1")
.env(CONFIG_ROOT_ENV, &loaded_config.root)
.env(
CONFIG_JSON_ENV,
serde_json::to_string(&loaded_config.config)
.context("failed to serialize mend config for compiler driver")?,
)
.env(CONFIG_FINGERPRINT_ENV, &loaded_config.fingerprint)
.env(FINDINGS_DIR_ENV, findings_dir)
.stdin(Stdio::inherit());
if let Some(tc) = &tc_override {
tc.apply(&mut command, target_directory);
}
run_cargo_command(&mut command, output_mode).with_context(|| {
format!(
"failed to run cargo rustc refresh for package {}",
package.name
)
})
}
fn run_cargo_command(
command: &mut Command,
output_mode: BuildOutputMode,
) -> Result<CommandOutcome> {
command.stdin(Stdio::inherit());
command.stderr(Stdio::piped());
match output_mode {
BuildOutputMode::Full => command.stdout(Stdio::inherit()),
BuildOutputMode::SuppressUnusedImportWarnings => command.stdout(Stdio::null()),
};
let mut child = command.spawn().context("failed to spawn cargo command")?;
let stderr = child
.stderr
.take()
.context("failed to capture cargo stderr")?;
let stderr_outcome = stream_cargo_stderr(stderr, output_mode)?;
let status = child.wait().context("failed to wait for cargo command")?;
Ok(CommandOutcome {
status,
saw_unused_import_warning: stderr_outcome.saw_unused_import_warning,
})
}
#[derive(Debug, Clone, Copy, Default)]
struct StderrObservation {
saw_unused_import_warning: bool,
}
fn stream_cargo_stderr(
stderr: std::process::ChildStderr,
output_mode: BuildOutputMode,
) -> Result<StderrObservation> {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
let mut block = Vec::new();
let mut printed_suppression_notice = false;
let mut saw_unused_import_warning = false;
loop {
line.clear();
let bytes = reader.read_line(&mut line)?;
if bytes == 0 {
flush_diagnostic_block(
&mut block,
&mut printed_suppression_notice,
&mut saw_unused_import_warning,
output_mode,
);
break;
}
let current = line.clone();
if is_progress_line(¤t) {
flush_diagnostic_block(
&mut block,
&mut printed_suppression_notice,
&mut saw_unused_import_warning,
output_mode,
);
eprint!("{current}");
continue;
}
if current.trim().is_empty() {
block.push(current);
flush_diagnostic_block(
&mut block,
&mut printed_suppression_notice,
&mut saw_unused_import_warning,
output_mode,
);
} else {
block.push(current);
}
}
Ok(StderrObservation {
saw_unused_import_warning,
})
}
fn is_progress_line(line: &str) -> bool {
let sanitized = sanitize_for_match(line);
let trimmed = sanitized.trim_start();
if trimmed.contains("warning:") || trimmed.contains("error:") {
return false;
}
trimmed.starts_with("Blocking waiting for file lock")
|| trimmed.starts_with("Checking ")
|| trimmed.starts_with("Compiling ")
|| trimmed.starts_with("Finished ")
|| trimmed.starts_with("Fresh ")
}
fn sanitize_for_match(line: &str) -> String {
let mut sanitized = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek().copied() == Some('[') {
chars.next();
for next in chars.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
}
continue;
}
sanitized.push(ch);
}
sanitized
}
fn classify_diagnostic_block(
block: &[String],
printed_suppression_notice: bool,
) -> DiagnosticBlockKind {
let first_non_empty = block.iter().find(|line| !line.trim().is_empty());
first_non_empty.map_or(DiagnosticBlockKind::ForwardedDiagnostic, |line| {
let sanitized = sanitize_for_match(line);
let trimmed = sanitized.trim_start();
let contains_unused_import_warning = trimmed.contains("warning: unused import:")
|| trimmed.contains("warning: unused imports:");
let contains_generated_warning_summary = printed_suppression_notice
&& trimmed.contains("warning: `")
&& ((trimmed.contains(" generated 1 warning ")
|| trimmed.contains(" generated ") && trimmed.contains(" warnings "))
|| trimmed.contains("to apply 1 suggestion")
|| trimmed.contains("to apply ") && trimmed.contains(" suggestions"));
if contains_unused_import_warning || contains_generated_warning_summary {
DiagnosticBlockKind::SuppressedUnusedImport
} else {
DiagnosticBlockKind::ForwardedDiagnostic
}
})
}
fn flush_diagnostic_block(
block: &mut Vec<String>,
printed_suppression_notice: &mut bool,
saw_unused_import_warning: &mut bool,
output_mode: BuildOutputMode,
) {
if block.is_empty() {
return;
}
match classify_diagnostic_block(block, *printed_suppression_notice) {
DiagnosticBlockKind::SuppressedUnusedImport => {
*saw_unused_import_warning = true;
match output_mode {
BuildOutputMode::SuppressUnusedImportWarnings if !*printed_suppression_notice => {
eprintln!(
"mend: suppressing `unused import` warning during `--fix-pub-use` \
discovery"
);
*printed_suppression_notice = true;
},
BuildOutputMode::Full => {
for line in block.iter() {
eprint!("{line}");
}
},
BuildOutputMode::SuppressUnusedImportWarnings => {},
}
},
DiagnosticBlockKind::ForwardedDiagnostic => {
for line in block.iter() {
eprint!("{line}");
}
},
}
block.clear();
}
fn refresh_rustc_args() -> Vec<String> {
vec![
"--".to_string(),
format!("--cfg=mend_refresh_{}", std::process::id()),
]
}
fn cache_is_current_for(
findings_dir: &Path,
package_root: &Path,
source_root: &Path,
loaded_config: &LoadedConfig,
) -> bool {
let cache_path = findings_dir.join(cache_filename_for(package_root));
let Ok(text) = fs::read_to_string(&cache_path) else {
return false;
};
let Ok(cache_metadata) = fs::metadata(&cache_path) else {
return false;
};
let Ok(cache_modified) = cache_metadata.modified() else {
return false;
};
let Ok(stored) = serde_json::from_str::<StoredReport>(&text) else {
return false;
};
stored.version == FINDINGS_SCHEMA_VERSION
&& stored.analysis_fingerprint == current_analysis_fingerprint()
&& stored.package_root == package_root.to_string_lossy()
&& stored.config_fingerprint == loaded_config.fingerprint
&& !package_sources_newer_than(package_root, source_root, cache_modified)
}
fn package_sources_newer_than(
package_root: &Path,
source_root: &Path,
reference: std::time::SystemTime,
) -> bool {
let manifest = package_root.join("Cargo.toml");
if file_is_newer_than(&manifest, reference) {
return true;
}
rust_sources_newer_than(source_root, reference)
}
fn file_is_newer_than(path: &Path, reference: std::time::SystemTime) -> bool {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| modified > reference)
}
fn rust_sources_newer_than(dir: &Path, reference: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if rust_sources_newer_than(&path, reference) {
return true;
}
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs")
&& file_is_newer_than(&path, reference)
{
return true;
}
}
false
}
fn load_report(findings_dir: &Path, selection: &Selection) -> Result<Report> {
let mut findings = Vec::new();
let mut pub_use_fix_facts = Vec::new();
let selected_roots: Vec<String> = selection
.package_roots
.iter()
.map(|root| root.to_string_lossy().into_owned())
.collect();
for entry in fs::read_dir(findings_dir).with_context(|| {
format!(
"failed to read findings directory {}",
findings_dir.display()
)
})? {
let entry = entry?;
if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let text = fs::read_to_string(entry.path())
.with_context(|| format!("failed to read findings file {}", entry.path().display()))?;
let Ok(stored) = serde_json::from_str::<StoredReport>(&text) else {
continue;
};
if stored.version != FINDINGS_SCHEMA_VERSION {
continue;
}
if stored.analysis_fingerprint != current_analysis_fingerprint() {
continue;
}
let matches_selected_root = selected_roots
.iter()
.any(|root| root == &stored.package_root)
|| (stored.package_root.is_empty() && selected_roots.len() == 1);
if !matches_selected_root {
continue;
}
for finding in stored.findings {
findings.push(Finding {
severity: finding.severity,
code: finding.code,
path: relativize_path(&finding.path, selection.analysis_root.as_path()),
line: finding.line,
column: finding.column,
highlight_len: finding.highlight_len,
source_line: finding.source_line,
item: finding.item,
message: finding.message,
suggestion: finding.suggestion,
fix_support: finding.fix_support,
related: finding.related,
});
}
for fact in stored.pub_use_fix_facts {
pub_use_fix_facts.push(PubUseFixFact {
child_path: relativize_path(
&fact.child_path,
selection.analysis_root.as_path(),
),
child_line: fact.child_line,
child_item_name: fact.child_item_name,
parent_path: relativize_path(
&fact.parent_path,
selection.analysis_root.as_path(),
),
parent_line: fact.parent_line,
child_module: fact.child_module,
});
}
}
findings.sort_by(|a, b| {
(
a.severity, &a.path, a.line, a.column, &a.code, &a.item, &a.message,
)
.cmp(&(
b.severity, &b.path, b.line, b.column, &b.code, &b.item, &b.message,
))
});
findings.dedup_by(|a, b| {
a.severity == b.severity
&& a.code == b.code
&& a.path == b.path
&& a.line == b.line
&& a.column == b.column
&& a.message == b.message
&& a.item == b.item
});
Ok(Report {
root: selection_root_string(selection.analysis_root.as_path()),
summary: ReportSummary::default(),
findings,
facts: ReportFacts {
pub_use: PubUseFixFacts::from_vec(pub_use_fix_facts),
compiler_warnings: CompilerWarningFacts::None,
},
})
}
fn selection_root_string(root: &Path) -> String { root.display().to_string() }
fn relativize_path(path: &str, analysis_root: &Path) -> String {
let absolute = Path::new(path);
absolute.strip_prefix(analysis_root).map_or_else(
|_| path.to_string(),
|relative| relative.to_string_lossy().replace('\\', "/"),
)
}
fn config_relative_path(file_path: &Path, config_root: &Path) -> Option<String> {
file_path
.strip_prefix(config_root)
.ok()
.map(normalize_relative_path)
.or_else(|| {
let canonical_file = fs::canonicalize(file_path).ok()?;
let canonical_root = fs::canonicalize(config_root).ok()?;
canonical_file
.strip_prefix(canonical_root)
.ok()
.map(normalize_relative_path)
})
}
fn config_relative_path_for_settings(
file_path: &Path,
settings: &DriverSettings,
) -> Option<String> {
if file_path.is_relative() {
let workspace_relative = normalize_relative_path(file_path);
if settings.config_root.join(file_path).exists() {
return Some(workspace_relative);
}
let package_relative = settings.package_root.join(file_path);
return config_relative_path(&package_relative, &settings.config_root)
.or(Some(workspace_relative));
}
config_relative_path(file_path, &settings.config_root)
}
fn normalize_relative_path(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") }
trait IntoExitCode {
fn into_exit_code(self) -> ExitCode;
}
impl IntoExitCode for i32 {
fn into_exit_code(self) -> ExitCode {
ExitCode::from(u8::try_from(self).unwrap_or(EXIT_CODE_ERROR))
}
}
impl IntoExitCode for ExitCode {
fn into_exit_code(self) -> ExitCode { self }
}
fn exit_code_from_i32(code: i32) -> ExitCode {
let normalized_code = u8::try_from(code).unwrap_or(EXIT_CODE_ERROR);
ExitCode::from(normalized_code)
}
fn collect_and_store_findings(tcx: TyCtxt<'_>, settings: &DriverSettings) -> Result<bool> {
let crate_root_file = real_file_path(tcx, tcx.def_span(CRATE_DEF_ID))
.context("failed to determine local crate root file")?;
let Some(src_root) = analysis_source_root_for(&crate_root_file, &settings.package_root) else {
return Ok(false);
};
let mut sink = FindingsSink::default();
let crate_items = tcx.hir_crate_items(());
let ctx = VisibilityContext {
tcx,
settings,
src_root: &src_root,
root_module: &crate_root_file,
effective_visibilities: tcx.effective_visibilities(()),
};
for item_id in crate_items.free_items() {
let item = tcx.hir_item(item_id);
analyze_item(&ctx, item, &mut sink)?;
}
for item_id in crate_items.impl_items() {
let item = tcx.hir_impl_item(item_id);
analyze_impl_item(&ctx, item, &mut sink)?;
}
for item_id in crate_items.foreign_items() {
let item = tcx.hir_foreign_item(item_id);
analyze_foreign_item(&ctx, item, &mut sink)?;
}
let output_path = settings
.findings_dir
.join(cache_filename_for(&settings.package_root));
if !sink.findings.is_empty() {
sink.findings.sort_by(|a, b| {
(&a.path, a.line, a.column, &a.code, &a.item, &a.message)
.cmp(&(&b.path, b.line, b.column, &b.code, &b.item, &b.message))
});
sink.findings.dedup_by(|a, b| {
a.code == b.code
&& a.path == b.path
&& a.line == b.line
&& a.column == b.column
&& a.message == b.message
&& a.item == b.item
});
}
let report = StoredReport {
version: FINDINGS_SCHEMA_VERSION,
analysis_fingerprint: settings.analysis_fingerprint.clone(),
package_root: settings.package_root.to_string_lossy().into_owned(),
config_fingerprint: settings.config_fingerprint.clone(),
findings: sink.findings,
pub_use_fix_facts: sink.pub_use_fix_facts,
saw_unused_import_warnings: false,
};
fs::write(&output_path, serde_json::to_vec_pretty(&report)?)
.with_context(|| format!("failed to write findings file {}", output_path.display()))?;
Ok(true)
}
fn analysis_source_root_for(crate_root_file: &Path, package_root: &Path) -> Option<PathBuf> {
let source_root = crate_root_file.parent()?.to_path_buf();
let canonical_crate_root =
fs::canonicalize(crate_root_file).unwrap_or_else(|_| crate_root_file.to_path_buf());
let canonical_package_root =
fs::canonicalize(package_root).unwrap_or_else(|_| package_root.to_path_buf());
let relative = canonical_crate_root
.strip_prefix(&canonical_package_root)
.ok()?;
let first_component = relative.components().next()?.as_os_str().to_str()?;
matches!(first_component, "src" | "examples" | "tests" | "benches").then_some(source_root)
}
#[derive(Default)]
struct FindingsSink {
findings: Vec<StoredFinding>,
pub_use_fix_facts: Vec<StoredPubUseFixFact>,
}
struct VisibilityContext<'a, 'tcx> {
tcx: TyCtxt<'tcx>,
settings: &'a DriverSettings,
src_root: &'a Path,
root_module: &'a Path,
effective_visibilities: &'a rustc_middle::middle::privacy::EffectiveVisibilities,
}
struct ItemInfo<'a> {
def_id: LocalDefId,
file_path: &'a Path,
vis_text: &'a str,
kind_label: Option<&'static str>,
item_name: Option<&'a str>,
highlight_span: Span,
is_module_item: bool,
}
struct SuspiciousPubInput<'a> {
def_id: LocalDefId,
file_path: &'a Path,
config_rel_path: Option<&'a str>,
parent_is_public: bool,
module_location: ModuleLocation,
crate_kind: CrateKind,
kind_label: Option<&'static str>,
item_name: Option<&'a str>,
highlight_span: Span,
}
fn cache_filename_for(package_root: &Path) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
package_root.hash(&mut hasher);
format!("{:016x}.json", hasher.finish())
}
fn analyze_item(
ctx: &VisibilityContext<'_, '_>,
item: &Item<'_>,
sink: &mut FindingsSink,
) -> Result<()> {
if item.span.from_expansion() || item.vis_span.from_expansion() {
return Ok(());
}
let Some(file_path) = real_file_path(ctx.tcx, item.vis_span) else {
return Ok(());
};
let Some(vis_text) = visibility_text(ctx.tcx, item.vis_span)? else {
return Ok(());
};
let item_name = item.kind.ident().map(|ident| ident.to_string());
if vis_text == "pub"
&& is_boundary_file(ctx.src_root, ctx.root_module, &file_path)
&& matches!(item.kind, ItemKind::Use(..))
&& use_item_contains_glob(ctx.tcx, item.span)?
{
sink.findings.push(build_finding(
ctx.tcx,
&file_path,
item.span,
FindingParams {
severity: Severity::Warning,
code: DiagnosticCode::WildcardParentPubUse,
item: None,
message: String::new(),
suggestion: None,
fix_support: FixSupport::None,
related: None,
},
)?);
}
record_visibility_findings(
ctx,
&ItemInfo {
def_id: item.owner_id.def_id,
file_path: &file_path,
vis_text: &vis_text,
kind_label: item_kind_label(item.kind),
item_name: item_name.as_deref(),
highlight_span: highlight_span(
item.vis_span,
item.kind.ident().map(|ident| ident.span),
),
is_module_item: matches!(item.kind, ItemKind::Mod(..)),
},
sink,
)
}
fn analyze_impl_item(
ctx: &VisibilityContext<'_, '_>,
item: &ImplItem<'_>,
sink: &mut FindingsSink,
) -> Result<()> {
let Some(vis_span) = item.vis_span() else {
return Ok(());
};
if item.span.from_expansion() || vis_span.from_expansion() {
return Ok(());
}
let Some(file_path) = real_file_path(ctx.tcx, vis_span) else {
return Ok(());
};
let Some(vis_text) = visibility_text(ctx.tcx, vis_span)? else {
return Ok(());
};
let item_name = item.ident.to_string();
record_visibility_findings(
ctx,
&ItemInfo {
def_id: item.owner_id.def_id,
file_path: &file_path,
vis_text: &vis_text,
kind_label: Some(impl_item_kind_label(item.kind)),
item_name: Some(item_name.as_str()),
highlight_span: highlight_span(vis_span, Some(item.ident.span)),
is_module_item: false,
},
sink,
)
}
fn analyze_foreign_item(
ctx: &VisibilityContext<'_, '_>,
item: &ForeignItem<'_>,
sink: &mut FindingsSink,
) -> Result<()> {
if item.span.from_expansion() || item.vis_span.from_expansion() {
return Ok(());
}
let Some(file_path) = real_file_path(ctx.tcx, item.vis_span) else {
return Ok(());
};
let Some(vis_text) = visibility_text(ctx.tcx, item.vis_span)? else {
return Ok(());
};
let item_name = item.ident.to_string();
record_visibility_findings(
ctx,
&ItemInfo {
def_id: item.owner_id.def_id,
file_path: &file_path,
vis_text: &vis_text,
kind_label: Some(foreign_item_kind_label(item.kind)),
item_name: Some(item_name.as_str()),
highlight_span: highlight_span(item.vis_span, Some(item.ident.span)),
is_module_item: false,
},
sink,
)
}
fn record_visibility_findings(
ctx: &VisibilityContext<'_, '_>,
item: &ItemInfo<'_>,
sink: &mut FindingsSink,
) -> Result<()> {
let crate_kind = if ctx.root_module.file_name().and_then(|name| name.to_str()) == Some("lib.rs")
{
CrateKind::Library
} else {
CrateKind::Binary
};
let config_rel_path = config_relative_path_for_settings(item.file_path, ctx.settings);
let parent_module = ctx.tcx.parent_module_from_def_id(item.def_id);
let parent_is_public = ctx
.tcx
.local_visibility(parent_module.to_local_def_id())
.is_public();
let module_location = resolve_module_location(ctx.tcx, parent_module.to_local_def_id());
if matches!(item.vis_text, "pub(crate)")
&& !allow_pub_crate_by_policy(crate_kind, module_location, parent_is_public)
{
sink.findings.push(build_finding(
ctx.tcx,
item.file_path,
item.highlight_span,
FindingParams {
severity: Severity::Error,
code: DiagnosticCode::ForbiddenPubCrate,
item: None,
message: "use of `pub(crate)` is forbidden by policy".to_string(),
suggestion: Some(forbidden_pub_crate_help(module_location).to_string()),
fix_support: FixSupport::None,
related: None,
},
)?);
}
if item.vis_text.starts_with("pub(in crate::") {
sink.findings.push(build_finding(
ctx.tcx,
item.file_path,
item.highlight_span,
FindingParams {
severity: Severity::Error,
code: DiagnosticCode::ForbiddenPubInCrate,
item: None,
message: "use of `pub(in crate::...)` is forbidden by policy".to_string(),
suggestion: None,
fix_support: FixSupport::None,
related: None,
},
)?);
}
if item.is_module_item && item.vis_text.starts_with("pub") {
let allowlisted = config_rel_path.as_ref().is_some_and(|path| {
ctx.settings
.config
.allow_pub_mod
.iter()
.any(|allowed| allowed == path)
});
if !allowlisted {
sink.findings.push(build_finding(
ctx.tcx,
item.file_path,
item.highlight_span,
FindingParams {
severity: Severity::Error,
code: DiagnosticCode::ReviewPubMod,
item: item.item_name.map(str::to_owned),
message: "`pub mod` requires explicit review or allowlisting".to_string(),
suggestion: None,
fix_support: FixSupport::None,
related: None,
},
)?);
}
}
if item.vis_text == "pub" && !is_boundary_file(ctx.src_root, ctx.root_module, item.file_path) {
maybe_record_suspicious_pub(
ctx,
&SuspiciousPubInput {
def_id: item.def_id,
file_path: item.file_path,
config_rel_path: config_rel_path.as_deref(),
parent_is_public,
module_location,
crate_kind,
kind_label: item.kind_label,
item_name: item.item_name,
highlight_span: item.highlight_span,
},
sink,
)?;
}
Ok(())
}
fn maybe_record_suspicious_pub(
ctx: &VisibilityContext<'_, '_>,
input: &SuspiciousPubInput<'_>,
sink: &mut FindingsSink,
) -> Result<()> {
let Some(kind_label) = input.kind_label else {
return Ok(());
};
match classify_suspicious_pub(ctx, input)? {
SuspiciousPubAssessment::Allowed(_) => {},
SuspiciousPubAssessment::ReviewInternalParentFacade { related } => {
let Some(status) = input
.item_name
.map(|name| {
parent_facade_export_status(ctx.settings, ctx.src_root, input.file_path, name)
})
.transpose()?
.flatten()
else {
return Ok(());
};
sink.findings.push(build_line_finding(
&status.parent_path,
status.parent_line,
FindingParams {
severity: Severity::Warning,
code: DiagnosticCode::InternalParentPubUseFacade,
item: input.item_name.map(|name| format!("pub use {name}")),
message: String::from(
"this `pub use` is used inside its parent module subtree",
),
suggestion: None,
fix_support: FixSupport::InternalParentFacade,
related,
},
)?);
},
SuspiciousPubAssessment::Warn {
fix_support,
related,
stale_parent_pub_use,
} => {
sink.findings.push(build_finding(
ctx.tcx,
input.file_path,
input.highlight_span,
FindingParams {
severity: Severity::Warning,
code: DiagnosticCode::SuspiciousPub,
item: input.item_name.map(|name| format!("{kind_label} {name}")),
message: suspicious_pub_note(input.crate_kind, kind_label),
suggestion: None,
fix_support,
related,
},
)?);
if let (Some(status), Some(item_name)) = (stale_parent_pub_use, input.item_name)
&& fix_support == FixSupport::FixPubUse
{
let display = line_display(ctx.tcx, input.file_path, input.highlight_span)?;
let Some(child_module) = input
.file_path
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| *stem != "mod")
.map(str::to_string)
else {
return Ok(());
};
sink.pub_use_fix_facts.push(StoredPubUseFixFact {
child_path: input.file_path.to_string_lossy().into_owned(),
child_line: display.line,
child_item_name: item_name.to_string(),
parent_path: status.parent_path.to_string_lossy().into_owned(),
parent_line: status.parent_line,
child_module,
});
}
},
}
Ok(())
}
fn classify_suspicious_pub(
ctx: &VisibilityContext<'_, '_>,
input: &SuspiciousPubInput<'_>,
) -> Result<SuspiciousPubAssessment> {
if let Some(allowance) = basic_suspicious_pub_allowance(
ctx.settings,
ctx.effective_visibilities,
input.def_id,
input.config_rel_path,
input.parent_is_public,
input.item_name,
) {
return Ok(SuspiciousPubAssessment::Allowed(allowance));
}
let parent_facade_export = input
.item_name
.map(|name| parent_facade_export_status(ctx.settings, ctx.src_root, input.file_path, name))
.transpose()?
.flatten();
if let Some(assessment) = assess_parent_facade_usage(parent_facade_export.as_ref()) {
return Ok(assessment);
}
if let Some(allowance) = assess_signature_exposure_allowance(
ctx.settings,
ctx.src_root,
input.file_path,
input.item_name,
)? {
return Ok(SuspiciousPubAssessment::Allowed(allowance));
}
let stale_result = parent_facade_export.as_ref().and_then(|status| {
let message = match status.usage {
ParentFacadeUsage::Unused => format!(
"parent module also has an `unused import` warning for this `pub use` at {}:{}",
status.parent_rel_path, status.parent_line
),
ParentFacadeUsage::UsedInsideParentSubtreeByCratePath
| ParentFacadeUsage::UsedInsideParentSubtreeByCrateImport => format!(
"parent `pub use` at {}:{} is only used through crate-relative paths inside its own subtree",
status.parent_rel_path, status.parent_line
),
ParentFacadeUsage::UsedInsideParentSubtreeByRelativeImport
| ParentFacadeUsage::UsedInsideParentSubtreeByRelativePath
| ParentFacadeUsage::UsedOutsideParentSubtree => return None,
};
Some((message, status))
});
if matches!(input.module_location, ModuleLocation::TopLevelPrivateModule)
&& stale_result.is_none()
{
return Ok(SuspiciousPubAssessment::Allowed(
AllowanceReason::TopLevelPrivateModulePolicy,
));
}
let (related, fix_support, stale_parent_pub_use) = match stale_result {
Some((message, status)) => {
let fix = if status.fix_supported {
FixSupport::FixPubUse
} else {
FixSupport::NeedsManualPubUseCleanup
};
(Some(message), fix, Some(status.clone()))
},
None => (None, FixSupport::None, None),
};
Ok(SuspiciousPubAssessment::Warn {
fix_support,
related,
stale_parent_pub_use,
})
}
fn basic_suspicious_pub_allowance(
settings: &DriverSettings,
effective_visibilities: &rustc_middle::middle::privacy::EffectiveVisibilities,
def_id: LocalDefId,
config_rel_path: Option<&str>,
parent_is_public: bool,
item_name: Option<&str>,
) -> Option<AllowanceReason> {
let item_key = config_rel_path.and_then(|path| item_name.map(|name| format!("{path}::{name}")));
let allowlisted = item_key.as_ref().is_some_and(|key| {
settings
.config
.allow_pub_items
.iter()
.any(|allowed| allowed == key)
});
if allowlisted {
return Some(AllowanceReason::Allowlist);
}
if parent_is_public {
return Some(AllowanceReason::ParentIsPublic);
}
if effective_visibilities.is_public_at_level(def_id, Level::Reachable) {
return Some(AllowanceReason::ReachablePublicApi);
}
None
}
fn assess_parent_facade_usage(
parent_facade_export: Option<&ParentFacadeExportStatus>,
) -> Option<SuspiciousPubAssessment> {
let status = parent_facade_export?;
if status.visibility == ParentFacadeVisibility::Super
&& !matches!(status.usage, ParentFacadeUsage::Unused)
{
return Some(SuspiciousPubAssessment::Allowed(
AllowanceReason::InternalParentFacadeBoundary,
));
}
match status.usage {
ParentFacadeUsage::UsedOutsideParentSubtree => Some(SuspiciousPubAssessment::Allowed(
AllowanceReason::ParentFacadeUsedOutsideParent,
)),
ParentFacadeUsage::UsedInsideParentSubtreeByRelativePath
| ParentFacadeUsage::UsedInsideParentSubtreeByRelativeImport => {
let related = Some(format!(
"parent module uses this item as an internal facade at {}:{}",
status.parent_rel_path, status.parent_line
));
Some(SuspiciousPubAssessment::ReviewInternalParentFacade { related })
},
ParentFacadeUsage::UsedInsideParentSubtreeByCratePath
| ParentFacadeUsage::UsedInsideParentSubtreeByCrateImport
| ParentFacadeUsage::Unused => None,
}
}
fn assess_signature_exposure_allowance(
settings: &DriverSettings,
src_root: &Path,
file_path: &Path,
item_name: Option<&str>,
) -> Result<Option<AllowanceReason>> {
let Some(item_name) = item_name else {
return Ok(None);
};
if child_item_is_exposed_by_other_crate_visible_signature(
settings, src_root, file_path, item_name,
)? || impl_item_is_exposed_by_exported_self_type(settings, src_root, file_path, item_name)?
|| child_item_is_exposed_by_sibling_boundary_signature(
settings, src_root, file_path, item_name,
)?
|| parent_boundary_public_signature_exposes_child_used_outside_parent(
settings, src_root, file_path, item_name,
)?
{
return Ok(Some(AllowanceReason::ExposedByOtherCrateVisibleSignature));
}
Ok(None)
}
struct FindingParams {
severity: Severity,
code: DiagnosticCode,
item: Option<String>,
message: String,
suggestion: Option<String>,
fix_support: FixSupport,
related: Option<String>,
}
fn build_finding(
tcx: TyCtxt<'_>,
file_path: &Path,
highlight_span: Span,
params: FindingParams,
) -> Result<StoredFinding> {
let display = line_display(tcx, file_path, highlight_span)?;
Ok(StoredFinding {
severity: params.severity,
code: params.code,
path: file_path.to_string_lossy().into_owned(),
line: display.line,
column: display.column,
highlight_len: display.highlight_len,
source_line: display.source_line,
item: params.item,
message: params.message,
suggestion: params.suggestion,
fix_support: params.fix_support,
related: params.related,
})
}
fn build_line_finding(
file_path: &Path,
line: usize,
params: FindingParams,
) -> Result<StoredFinding> {
let text = fs::read_to_string(file_path)
.with_context(|| format!("failed to read source file {}", file_path.display()))?;
let source_line = text
.lines()
.nth(line.saturating_sub(1))
.unwrap_or_default()
.to_string();
let trimmed = source_line.trim_start();
let column = source_line.len().saturating_sub(trimmed.len()) + 1;
let highlight_len = trimmed
.find(char::is_whitespace)
.unwrap_or(trimmed.len())
.max(1);
Ok(StoredFinding {
severity: params.severity,
code: params.code,
path: file_path.to_string_lossy().into_owned(),
line,
column,
highlight_len,
source_line,
item: params.item,
message: params.message,
suggestion: params.suggestion,
fix_support: params.fix_support,
related: params.related,
})
}
const fn module_location(
parent_is_crate_root: bool,
grandparent_is_crate_root: bool,
great_grandparent_is_crate_root: bool,
) -> ModuleLocation {
if parent_is_crate_root {
ModuleLocation::CrateRoot
} else if grandparent_is_crate_root || great_grandparent_is_crate_root {
ModuleLocation::TopLevelPrivateModule
} else {
ModuleLocation::NestedModule
}
}
fn resolve_module_location(tcx: TyCtxt<'_>, parent_def: LocalDefId) -> ModuleLocation {
let parent_is_crate_root = parent_def == CRATE_DEF_ID;
let grandparent_def = if parent_is_crate_root {
None
} else {
Some(tcx.parent_module_from_def_id(parent_def).to_local_def_id())
};
let grandparent_is_crate_root = grandparent_def == Some(CRATE_DEF_ID);
let great_grandparent_is_crate_root = match grandparent_def {
Some(gp) if gp != CRATE_DEF_ID => {
tcx.parent_module_from_def_id(gp).to_local_def_id() == CRATE_DEF_ID
},
_ => false,
};
module_location(
parent_is_crate_root,
grandparent_is_crate_root,
great_grandparent_is_crate_root,
)
}
fn parent_facade_export_status(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<Option<ParentFacadeExportStatus>> {
let Some(initial_boundary) = parent_boundary_for_child(src_root, child_file) else {
return Ok(None);
};
let mut current_child: PathBuf = child_file.to_path_buf();
let mut parent_boundary = initial_boundary;
let (exported_names, parent_source) = loop {
let Some(child_module_name) =
module_paths::module_name_for_child_boundary_file(¤t_child)
else {
return Ok(None);
};
let source = fs::read_to_string(&parent_boundary.boundary_file).with_context(|| {
format!(
"failed to read parent boundary file {}",
parent_boundary.boundary_file.display()
)
})?;
let exports = exported_names_from_parent_boundary(&source, child_module_name, item_name)?;
if !exports.explicit.is_empty() {
break (exports, source);
}
current_child.clone_from(&parent_boundary.boundary_file);
let Some(next_boundary) = parent_of_boundary(src_root, ¤t_child) else {
return Ok(None);
};
parent_boundary = next_boundary;
};
let parent_rel_path = parent_boundary
.boundary_file
.strip_prefix(src_root)
.unwrap_or(&parent_boundary.boundary_file)
.to_string_lossy()
.replace('\\', "/");
let parent_line = first_line_matching(&parent_source, item_name).unwrap_or(1);
let usage = scan_facade_usage(settings, src_root, &parent_boundary, &exported_names)?;
Ok(Some(ParentFacadeExportStatus {
usage,
fix_supported: exported_names.fix_supported,
visibility: exported_names
.visibility
.unwrap_or(ParentFacadeVisibility::Public),
parent_path: parent_boundary.boundary_file,
parent_rel_path,
parent_line,
}))
}
fn scan_facade_usage(
settings: &DriverSettings,
src_root: &Path,
parent_boundary: &ParentBoundary,
exported_names: &ParentFacadeExports,
) -> Result<ParentFacadeUsage> {
let mut usage = ParentFacadeUsage::Unused;
for file in rust_source_files(src_root)? {
if file == parent_boundary.boundary_file {
continue;
}
let Some(current_module_path) = module_path_from_source_file(src_root, &file) else {
continue;
};
let source = fs::read_to_string(&file)
.with_context(|| format!("failed to read source file {}", file.display()))?;
match source_references_parent_export(
&source,
¤t_module_path,
&parent_boundary.module_path,
&exported_names.explicit,
) {
ParentFacadeReferenceUsage::None => {},
ParentFacadeReferenceUsage::Import(PathOrigin::Relative) => {
if matches!(usage, ParentFacadeUsage::Unused)
&& file.starts_with(&parent_boundary.subtree_root)
{
usage = ParentFacadeUsage::UsedInsideParentSubtreeByRelativeImport;
} else if !file.starts_with(&parent_boundary.subtree_root) {
usage = ParentFacadeUsage::UsedOutsideParentSubtree;
break;
}
},
ParentFacadeReferenceUsage::Import(PathOrigin::Crate) => {
if matches!(usage, ParentFacadeUsage::Unused)
&& file.starts_with(&parent_boundary.subtree_root)
{
usage = ParentFacadeUsage::UsedInsideParentSubtreeByCrateImport;
} else if !file.starts_with(&parent_boundary.subtree_root) {
usage = ParentFacadeUsage::UsedOutsideParentSubtree;
break;
}
},
ParentFacadeReferenceUsage::DirectPath(PathOrigin::Relative) => {
if file.starts_with(&parent_boundary.subtree_root) {
usage = ParentFacadeUsage::UsedInsideParentSubtreeByRelativePath;
} else {
usage = ParentFacadeUsage::UsedOutsideParentSubtree;
break;
}
},
ParentFacadeReferenceUsage::DirectPath(PathOrigin::Crate) => {
if file.starts_with(&parent_boundary.subtree_root) {
usage = ParentFacadeUsage::UsedInsideParentSubtreeByCratePath;
} else {
usage = ParentFacadeUsage::UsedOutsideParentSubtree;
break;
}
},
}
}
if !matches!(usage, ParentFacadeUsage::UsedOutsideParentSubtree)
&& workspace_source_mentions_parent_export_literal(
settings,
parent_boundary,
&exported_names.explicit,
)?
{
usage = ParentFacadeUsage::UsedOutsideParentSubtree;
}
Ok(usage)
}
fn workspace_source_mentions_parent_export_literal(
settings: &DriverSettings,
parent_boundary: &ParentBoundary,
exported_names: &[String],
) -> Result<bool> {
if settings.config_root == settings.package_root {
return Ok(false);
}
if parent_boundary.module_path.is_empty() {
return Ok(false);
}
let module_prefix = format!("crate::{}", parent_boundary.module_path.join("::"));
let findings_root = settings
.findings_dir
.parent()
.map_or_else(|| settings.findings_dir.clone(), Path::to_path_buf);
for file in rust_source_files(&settings.config_root)? {
if file.starts_with(&settings.package_root)
|| file.starts_with(&settings.findings_dir)
|| file.starts_with(&findings_root)
{
continue;
}
let source = fs::read_to_string(&file)
.with_context(|| format!("failed to read source file {}", file.display()))?;
if exported_names.iter().any(|name| {
let pattern = format!("{module_prefix}::{name}");
source.contains(&pattern)
}) {
return Ok(true);
}
}
Ok(false)
}
fn parent_boundary_for_child(src_root: &Path, child_file: &Path) -> Option<ParentBoundary> {
let parent_dir = child_file.parent()?;
let parent_mod_rs = parent_dir.join("mod.rs");
if parent_mod_rs.is_file() {
return Some(ParentBoundary {
boundary_file: parent_mod_rs,
subtree_root: parent_dir.to_path_buf(),
module_path: module_path_from_dir(src_root, parent_dir)?,
});
}
let parent_file = parent_dir.with_extension("rs");
if parent_file.is_file() {
return Some(ParentBoundary {
boundary_file: parent_file.clone(),
subtree_root: parent_dir.to_path_buf(),
module_path: module_path_from_boundary_file(src_root, &parent_file)?,
});
}
None
}
fn parent_of_boundary(src_root: &Path, boundary_file: &Path) -> Option<ParentBoundary> {
if boundary_file.file_name()?.to_str() != Some("mod.rs") {
return parent_boundary_for_child(src_root, boundary_file);
}
let container_dir = boundary_file.parent()?.parent()?;
let mod_rs = container_dir.join("mod.rs");
if mod_rs.is_file() {
return Some(ParentBoundary {
boundary_file: mod_rs,
subtree_root: container_dir.to_path_buf(),
module_path: module_path_from_dir(src_root, container_dir)?,
});
}
let named_file = container_dir.with_extension("rs");
if named_file.is_file() {
return Some(ParentBoundary {
boundary_file: named_file.clone(),
subtree_root: container_dir.to_path_buf(),
module_path: module_path_from_boundary_file(src_root, &named_file)?,
});
}
for name in ["lib.rs", "main.rs"] {
let root = container_dir.join(name);
if root.is_file() {
return Some(ParentBoundary {
boundary_file: root,
subtree_root: container_dir.to_path_buf(),
module_path: Vec::new(),
});
}
}
None
}
fn module_path_from_boundary_file(src_root: &Path, boundary_file: &Path) -> Option<Vec<String>> {
let relative = boundary_file.strip_prefix(src_root).ok()?;
let mut components = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
let last = components.last_mut()?;
*last = last.strip_suffix(".rs")?.to_string();
if matches!(components.as_slice(), [name] if name == "lib" || name == "main") {
Some(Vec::new())
} else {
Some(components)
}
}
fn module_path_from_source_file(src_root: &Path, source_file: &Path) -> Option<Vec<String>> {
if source_file.file_name().and_then(|name| name.to_str()) == Some("mod.rs") {
module_path_from_dir(src_root, source_file.parent()?)
} else {
module_path_from_boundary_file(src_root, source_file)
}
}
fn exported_names_from_parent_boundary(
parent_source: &str,
child_module_name: &str,
item_name: &str,
) -> Result<ParentFacadeExports> {
let file = syn::parse_file(parent_source).context("failed to parse parent boundary file")?;
let mut exported = ParentFacadeExports::default();
for item in &file.items {
let syn::Item::Use(item_use) = item else {
continue;
};
let Some(visibility) = parent_facade_visibility(&item_use.vis) else {
continue;
};
exported.visibility = Some(exported.visibility.map_or(visibility, |existing| existing));
collect_matching_pub_use_exports(item_use, child_module_name, item_name, &mut exported);
}
exported.explicit.sort();
exported.explicit.dedup();
Ok(exported)
}
fn collect_matching_pub_use_exports(
item_use: &ItemUse,
child_module_name: &str,
item_name: &str,
exported: &mut ParentFacadeExports,
) {
if pub_use_is_fix_supported(&item_use.tree, child_module_name, item_name) {
exported.fix_supported = true;
}
let mut paths = Vec::new();
flatten_use_tree(Vec::new(), &item_use.tree, &mut paths);
for path in paths {
let normalized = if path.first().is_some_and(|segment| segment == "self") {
&path[1..]
} else {
&path[..]
};
if normalized.len() >= 2
&& normalized[0] == child_module_name
&& normalized[1..].iter().any(|segment| segment == item_name)
&& let Some(export_name) = normalized.last()
{
exported.explicit.push(export_name.clone());
}
}
}
fn pub_use_is_fix_supported(tree: &UseTree, child_module_name: &str, item_name: &str) -> bool {
pub_use_is_fix_supported_with_prefix(Vec::new(), tree, child_module_name, item_name)
}
fn pub_use_is_fix_supported_with_prefix(
prefix: Vec<String>,
tree: &UseTree,
child_module_name: &str,
item_name: &str,
) -> bool {
match tree {
UseTree::Path(path) => {
let mut next = prefix;
next.push(path.ident.to_string());
pub_use_is_fix_supported_with_prefix(next, &path.tree, child_module_name, item_name)
},
UseTree::Name(name) => {
let normalized = if prefix.first().is_some_and(|segment| segment == "self") {
&prefix[1..]
} else {
&prefix[..]
};
normalized.len() == 1 && normalized[0] == child_module_name && name.ident == item_name
},
UseTree::Group(group) => group.items.iter().any(|item| {
pub_use_is_fix_supported_with_prefix(prefix.clone(), item, child_module_name, item_name)
}),
UseTree::Rename(_) | UseTree::Glob(_) => false,
}
}
fn parent_facade_visibility(vis: &syn::Visibility) -> Option<ParentFacadeVisibility> {
match vis {
syn::Visibility::Public(_) => Some(ParentFacadeVisibility::Public),
syn::Visibility::Restricted(restricted)
if restricted.path.segments.len() == 1
&& restricted.path.segments[0].ident == "super" =>
{
Some(ParentFacadeVisibility::Super)
},
_ => None,
}
}
fn flatten_use_tree(prefix: Vec<String>, tree: &UseTree, out: &mut Vec<Vec<String>>) {
match tree {
UseTree::Path(path) => {
let mut next = prefix;
next.push(path.ident.to_string());
flatten_use_tree(next, &path.tree, out);
},
UseTree::Name(name) => {
let mut next = prefix;
next.push(name.ident.to_string());
out.push(next);
},
UseTree::Rename(rename) => {
let mut next = prefix;
next.push(rename.ident.to_string());
next.push(rename.rename.to_string());
out.push(next);
},
UseTree::Group(group) => {
for item in &group.items {
flatten_use_tree(prefix.clone(), item, out);
}
},
UseTree::Glob(_) => {
let mut next = prefix;
next.push("*".to_string());
out.push(next);
},
}
}
fn use_item_contains_glob(tcx: TyCtxt<'_>, span: Span) -> Result<bool> {
let snippet = tcx.sess.source_map().span_to_snippet(span).map_err(|err| {
anyhow::anyhow!("failed to extract use item snippet for span {span:?}: {err:?}")
})?;
Ok(snippet.contains('*'))
}
fn first_line_matching(source: &str, needle: &str) -> Option<usize> {
source
.lines()
.position(|line| line.contains(needle))
.map(|index| index + 1)
}
fn module_path_from_dir(src_root: &Path, module_dir: &Path) -> Option<Vec<String>> {
let relative = module_dir.strip_prefix(src_root).ok()?;
let components = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
(!components.is_empty()).then_some(components)
}
fn rust_source_files(src_root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_rust_source_files(src_root, &mut files)?;
Ok(files)
}
fn collect_rust_source_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(dir)
.with_context(|| format!("failed to read source directory {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_rust_source_files(&path, files)?;
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
files.push(path);
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PathOrigin {
Relative,
Crate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParentFacadeReferenceUsage {
None,
Import(PathOrigin),
DirectPath(PathOrigin),
}
fn source_references_parent_export(
source: &str,
current_module_path: &[String],
module_path: &[String],
exported_names: &[String],
) -> ParentFacadeReferenceUsage {
let Ok(file) = syn::parse_file(source) else {
return ParentFacadeReferenceUsage::None;
};
let mut visitor =
ParentExportPathVisitor::new(current_module_path, module_path, exported_names);
visitor.visit_file(&file);
match (visitor.found_direct_origin, visitor.found_import_usage) {
(Some(origin), _) => ParentFacadeReferenceUsage::DirectPath(origin),
(None, usage) => usage,
}
}
fn resolve_module_relative_paths(
raw: &[String],
current_module_path: &[String],
) -> Vec<Vec<String>> {
if raw.is_empty() {
return Vec::new();
}
if raw.first().map(String::as_str) == Some("crate") {
return vec![raw[1..].to_vec()];
}
if raw.first().map(String::as_str) == Some("self") {
let mut resolved = current_module_path.to_vec();
resolved.extend(raw[1..].iter().cloned());
return vec![resolved];
}
if raw.first().map(String::as_str) == Some("super") {
let mut index = 0usize;
let mut resolved = current_module_path.to_vec();
while raw.get(index).is_some_and(|segment| segment == "super") {
if resolved.pop().is_none() {
return Vec::new();
}
index += 1;
}
if raw.get(index).is_some_and(|segment| segment == "self") {
index += 1;
}
resolved.extend(raw[index..].iter().cloned());
return vec![resolved];
}
(0..=current_module_path.len())
.map(|prefix_len| {
let mut resolved = current_module_path[..prefix_len].to_vec();
resolved.extend(raw.iter().cloned());
resolved
})
.collect()
}
fn child_item_is_exposed_by_other_crate_visible_signature(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<bool> {
let child_source = fs::read_to_string(child_file)
.with_context(|| format!("failed to read child file {}", child_file.display()))?;
let file = syn::parse_file(&child_source)
.with_context(|| format!("failed to parse child file {}", child_file.display()))?;
for item in &file.items {
let Some(exposing_item_name) = public_item_name(item) else {
continue;
};
if exposing_item_name == item_name {
continue;
}
if !public_item_surface_mentions_name(item, item_name) {
continue;
}
if type_is_exposed_outside_parent(settings, src_root, child_file, &exposing_item_name)? {
return Ok(true);
}
}
for item in &file.items {
let syn::Item::Impl(item_impl) = item else {
continue;
};
let Some(self_type_name) = impl_self_type_name(item_impl) else {
continue;
};
if self_type_name == item_name {
continue;
}
if !outward_impl_surface_mentions_name(item_impl, item_name) {
continue;
}
if type_is_exposed_outside_parent(settings, src_root, child_file, &self_type_name)? {
return Ok(true);
}
}
Ok(false)
}
fn child_item_is_exposed_by_sibling_boundary_signature(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<bool> {
let Some(parent_boundary) = parent_boundary_for_child(src_root, child_file) else {
return Ok(false);
};
for candidate_file in rust_source_files(&parent_boundary.subtree_root)? {
if candidate_file == *child_file || candidate_file == parent_boundary.boundary_file {
continue;
}
let candidate_source = fs::read_to_string(&candidate_file).with_context(|| {
format!("failed to read candidate file {}", candidate_file.display())
})?;
let file = syn::parse_file(&candidate_source).with_context(|| {
format!(
"failed to parse candidate file {}",
candidate_file.display()
)
})?;
for item in &file.items {
let Some(exposing_item_name) = public_item_name(item) else {
continue;
};
if exposing_item_name == item_name {
continue;
}
if !public_item_surface_mentions_name(item, item_name) {
continue;
}
if type_is_exposed_outside_parent(
settings,
src_root,
&candidate_file,
&exposing_item_name,
)? {
return Ok(true);
}
}
for item in &file.items {
let syn::Item::Impl(item_impl) = item else {
continue;
};
let Some(self_type_name) = impl_self_type_name(item_impl) else {
continue;
};
if self_type_name == item_name {
continue;
}
if !outward_impl_surface_mentions_name(item_impl, item_name) {
continue;
}
if type_is_exposed_outside_parent(settings, src_root, &candidate_file, &self_type_name)?
{
return Ok(true);
}
}
}
Ok(false)
}
fn impl_item_is_exposed_by_exported_self_type(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<bool> {
let child_source = fs::read_to_string(child_file)
.with_context(|| format!("failed to read child file {}", child_file.display()))?;
let file = syn::parse_file(&child_source)
.with_context(|| format!("failed to parse child file {}", child_file.display()))?;
for item in &file.items {
let syn::Item::Impl(item_impl) = item else {
continue;
};
let Some(self_type_name) = impl_self_type_name(item_impl) else {
continue;
};
for impl_item in &item_impl.items {
let outward = item_impl.trait_.is_some();
let is_target = match impl_item {
syn::ImplItem::Fn(item)
if (outward || matches!(item.vis, syn::Visibility::Public(_)))
&& item.sig.ident == item_name =>
{
true
},
syn::ImplItem::Const(item)
if (outward || matches!(item.vis, syn::Visibility::Public(_)))
&& item.ident == item_name =>
{
true
},
syn::ImplItem::Type(item)
if (outward || matches!(item.vis, syn::Visibility::Public(_)))
&& item.ident == item_name =>
{
true
},
_ => false,
};
if is_target {
let definition_file = find_type_definition_file(child_file, &self_type_name)?;
let check_file = definition_file.as_deref().unwrap_or(child_file);
if type_is_exposed_outside_parent(settings, src_root, check_file, &self_type_name)?
{
return Ok(true);
}
}
}
}
Ok(false)
}
fn find_type_definition_file(child_file: &Path, type_name: &str) -> Result<Option<PathBuf>> {
let source = fs::read_to_string(child_file)
.with_context(|| format!("failed to read {}", child_file.display()))?;
if file_defines_type(&source, type_name)? {
return Ok(None);
}
let Some(parent_dir) = child_file.parent() else {
return Ok(None);
};
for entry in fs::read_dir(parent_dir)
.with_context(|| format!("failed to read directory {}", parent_dir.display()))?
{
let path = entry?.path();
if path == child_file
|| path.extension().and_then(|ext| ext.to_str()) != Some("rs")
|| path.is_dir()
{
continue;
}
let sibling_source = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
if file_defines_type(&sibling_source, type_name)? {
return Ok(Some(path));
}
}
Ok(None)
}
fn file_defines_type(source: &str, type_name: &str) -> Result<bool> {
let file = syn::parse_file(source).context("failed to parse source")?;
for item in &file.items {
let name = match item {
syn::Item::Struct(item) => &item.ident,
syn::Item::Enum(item) => &item.ident,
syn::Item::Type(item) => &item.ident,
syn::Item::Union(item) => &item.ident,
_ => continue,
};
if name == type_name {
return Ok(true);
}
}
Ok(false)
}
fn parent_boundary_public_signature_exposes_child_used_outside_parent(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<bool> {
let Some(parent_boundary) = parent_boundary_for_child(src_root, child_file) else {
return Ok(false);
};
let parent_source = fs::read_to_string(&parent_boundary.boundary_file).with_context(|| {
format!(
"failed to read parent boundary file {}",
parent_boundary.boundary_file.display()
)
})?;
let file = syn::parse_file(&parent_source).with_context(|| {
format!(
"failed to parse parent boundary file {}",
parent_boundary.boundary_file.display()
)
})?;
let mut exposing_names = Vec::new();
for item in &file.items {
let Some(exposing_item_name) = public_item_name(item) else {
continue;
};
if public_item_surface_mentions_name(item, item_name) {
exposing_names.push(exposing_item_name);
}
}
if exposing_names.is_empty() {
return Ok(false);
}
for source_file in rust_source_files(src_root)? {
if source_file == parent_boundary.boundary_file
|| source_file.starts_with(&parent_boundary.subtree_root)
{
continue;
}
let source = fs::read_to_string(&source_file)
.with_context(|| format!("failed to read source file {}", source_file.display()))?;
let Some(current_module_path) = module_path_from_source_file(src_root, &source_file) else {
continue;
};
if !matches!(
source_references_parent_export(
&source,
¤t_module_path,
&parent_boundary.module_path,
&exposing_names,
),
ParentFacadeReferenceUsage::None
) {
return Ok(true);
}
}
if workspace_source_mentions_parent_export_literal(settings, &parent_boundary, &exposing_names)?
{
return Ok(true);
}
Ok(false)
}
struct ParentExportPathVisitor<'a> {
current_module_path: &'a [String],
module_path: &'a [String],
exported_names: &'a [String],
found_direct_origin: Option<PathOrigin>,
found_import_usage: ParentFacadeReferenceUsage,
}
impl<'a> ParentExportPathVisitor<'a> {
const fn new(
current_module_path: &'a [String],
module_path: &'a [String],
exported_names: &'a [String],
) -> Self {
Self {
current_module_path,
module_path,
exported_names,
found_direct_origin: None,
found_import_usage: ParentFacadeReferenceUsage::None,
}
}
fn matching_origin(&self, path: &syn::Path) -> Option<PathOrigin> {
let raw_segments = path
.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>();
let origin = path_origin(&raw_segments);
resolve_module_relative_paths(&raw_segments, self.current_module_path)
.into_iter()
.find(|segments| {
segments.len() == self.module_path.len() + 1
&& segments[..self.module_path.len()] == *self.module_path
&& self
.exported_names
.iter()
.any(|name| name == &segments[self.module_path.len()])
})
.map(|_| origin)
}
}
impl<'ast> Visit<'ast> for ParentExportPathVisitor<'_> {
fn visit_item_use(&mut self, item_use: &'ast ItemUse) {
let mut paths = Vec::new();
flatten_use_tree(Vec::new(), &item_use.tree, &mut paths);
for path in paths {
let origin = path_origin(&path);
for resolved in resolve_module_relative_paths(&path, self.current_module_path) {
if resolved.len() != self.module_path.len() + 1 {
continue;
}
if resolved[..self.module_path.len()] == *self.module_path
&& self
.exported_names
.iter()
.any(|name| name == &resolved[self.module_path.len()])
{
self.found_import_usage = merge_reference_usage(
self.found_import_usage,
ParentFacadeReferenceUsage::Import(origin),
);
break;
}
}
}
syn::visit::visit_item_use(self, item_use);
}
fn visit_path(&mut self, path: &'ast syn::Path) {
if self.found_direct_origin.is_some() {
return;
}
if let Some(origin) = self.matching_origin(path) {
self.found_direct_origin = Some(origin);
return;
}
syn::visit::visit_path(self, path);
}
}
fn path_origin(raw: &[String]) -> PathOrigin {
if raw.first().map(String::as_str) == Some("crate") {
PathOrigin::Crate
} else {
PathOrigin::Relative
}
}
const fn merge_reference_usage(
current: ParentFacadeReferenceUsage,
next: ParentFacadeReferenceUsage,
) -> ParentFacadeReferenceUsage {
match (current, next) {
(ParentFacadeReferenceUsage::DirectPath(PathOrigin::Relative), _)
| (_, ParentFacadeReferenceUsage::DirectPath(PathOrigin::Relative)) => {
ParentFacadeReferenceUsage::DirectPath(PathOrigin::Relative)
},
(ParentFacadeReferenceUsage::Import(PathOrigin::Relative), _)
| (_, ParentFacadeReferenceUsage::Import(PathOrigin::Relative)) => {
ParentFacadeReferenceUsage::Import(PathOrigin::Relative)
},
(ParentFacadeReferenceUsage::DirectPath(PathOrigin::Crate), _)
| (_, ParentFacadeReferenceUsage::DirectPath(PathOrigin::Crate)) => {
ParentFacadeReferenceUsage::DirectPath(PathOrigin::Crate)
},
(ParentFacadeReferenceUsage::Import(PathOrigin::Crate), _)
| (_, ParentFacadeReferenceUsage::Import(PathOrigin::Crate)) => {
ParentFacadeReferenceUsage::Import(PathOrigin::Crate)
},
_ => ParentFacadeReferenceUsage::None,
}
}
fn public_item_name(item: &syn::Item) -> Option<String> {
match item {
syn::Item::Const(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.ident.to_string())
},
syn::Item::Enum(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.ident.to_string())
},
syn::Item::Fn(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.sig.ident.to_string())
},
syn::Item::Static(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.ident.to_string())
},
syn::Item::Struct(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.ident.to_string())
},
syn::Item::Trait(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.ident.to_string())
},
syn::Item::Type(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
Some(item.ident.to_string())
},
_ => None,
}
}
fn public_item_surface_mentions_name(item: &syn::Item, item_name: &str) -> bool {
let mut visitor = ItemSurfaceReferenceVisitor::new(item_name);
match item {
syn::Item::Const(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_type(&item.ty);
},
syn::Item::Enum(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
for variant in &item.variants {
match &variant.fields {
syn::Fields::Named(fields) => {
for field in &fields.named {
visitor.visit_type(&field.ty);
}
},
syn::Fields::Unnamed(fields) => {
for field in &fields.unnamed {
visitor.visit_type(&field.ty);
}
},
syn::Fields::Unit => {},
}
}
},
syn::Item::Fn(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_signature(&item.sig);
},
syn::Item::Static(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_type(&item.ty);
},
syn::Item::Struct(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
match &item.fields {
syn::Fields::Named(fields) => {
for field in &fields.named {
visitor.visit_type(&field.ty);
}
},
syn::Fields::Unnamed(fields) => {
for field in &fields.unnamed {
visitor.visit_type(&field.ty);
}
},
syn::Fields::Unit => {},
}
},
syn::Item::Trait(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
for trait_item in &item.items {
match trait_item {
syn::TraitItem::Fn(item) => visitor.visit_signature(&item.sig),
syn::TraitItem::Type(item) => {
if let Some((_, ty)) = &item.default {
visitor.visit_type(ty);
}
},
syn::TraitItem::Const(item) => visitor.visit_type(&item.ty),
_ => {},
}
}
},
syn::Item::Type(item) if matches!(item.vis, syn::Visibility::Public(_)) => {
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_type(&item.ty);
},
_ => {},
}
visitor.found
}
fn impl_self_type_name(item_impl: &syn::ItemImpl) -> Option<String> {
let syn::Type::Path(type_path) = item_impl.self_ty.as_ref() else {
return None;
};
if type_path.qself.is_some() {
return None;
}
type_path
.path
.segments
.last()
.map(|segment| segment.ident.to_string())
}
fn outward_impl_surface_mentions_name(item_impl: &syn::ItemImpl, item_name: &str) -> bool {
let mut visitor = ItemSurfaceReferenceVisitor::new(item_name);
let mut found_public_surface = false;
let outward = item_impl.trait_.is_some();
for impl_item in &item_impl.items {
match impl_item {
syn::ImplItem::Fn(item)
if outward || matches!(item.vis, syn::Visibility::Public(_)) =>
{
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_signature(&item.sig);
found_public_surface = true;
},
syn::ImplItem::Const(item)
if outward || matches!(item.vis, syn::Visibility::Public(_)) =>
{
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_type(&item.ty);
found_public_surface = true;
},
syn::ImplItem::Type(item)
if outward || matches!(item.vis, syn::Visibility::Public(_)) =>
{
if attributes_mention_name(&item.attrs, item_name) {
return true;
}
visitor.visit_type(&item.ty);
found_public_surface = true;
},
_ => {},
}
}
found_public_surface && visitor.found
}
fn type_is_exposed_outside_parent(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<bool> {
Ok(
parent_facade_export_status(settings, src_root, child_file, item_name)?
.is_some_and(|status| status.usage == ParentFacadeUsage::UsedOutsideParentSubtree)
|| public_reexport_exists_outside_parent(settings, src_root, child_file, item_name)?
|| child_item_is_exposed_by_other_crate_visible_signature(
settings, src_root, child_file, item_name,
)?
|| child_item_is_exposed_by_sibling_boundary_signature(
settings, src_root, child_file, item_name,
)?
|| parent_boundary_public_signature_exposes_child_used_outside_parent(
settings, src_root, child_file, item_name,
)?,
)
}
fn public_reexport_exists_outside_parent(
settings: &DriverSettings,
src_root: &Path,
child_file: &Path,
item_name: &str,
) -> Result<bool> {
let Some(parent_boundary) = parent_boundary_for_child(src_root, child_file) else {
return Ok(false);
};
let Some(child_module_path) = module_path_from_source_file(src_root, child_file) else {
return Ok(false);
};
for source_file in rust_source_files(src_root)? {
if source_file.starts_with(&parent_boundary.subtree_root) {
continue;
}
let source = fs::read_to_string(&source_file)
.with_context(|| format!("failed to read source file {}", source_file.display()))?;
let file = syn::parse_file(&source)
.with_context(|| format!("failed to parse source file {}", source_file.display()))?;
let Some(current_module_path) = module_path_from_source_file(src_root, &source_file) else {
continue;
};
for item in &file.items {
let syn::Item::Use(item_use) = item else {
continue;
};
let Some(_visibility) = parent_facade_visibility(&item_use.vis) else {
continue;
};
let mut paths = Vec::new();
flatten_use_tree(Vec::new(), &item_use.tree, &mut paths);
for path in paths {
for resolved in resolve_module_relative_paths(&path, ¤t_module_path) {
if resolved.len() != child_module_path.len() + 1 {
continue;
}
if resolved[..child_module_path.len()] == *child_module_path
&& resolved[child_module_path.len()] == item_name
{
return Ok(true);
}
}
}
}
}
if settings.config_root != settings.package_root {
let module_prefix = format!("crate::{}", child_module_path.join("::"));
let findings_root = settings
.findings_dir
.parent()
.map_or_else(|| settings.findings_dir.clone(), Path::to_path_buf);
for file in rust_source_files(&settings.config_root)? {
if file.starts_with(&settings.package_root)
|| file.starts_with(&settings.findings_dir)
|| file.starts_with(&findings_root)
{
continue;
}
let source = fs::read_to_string(&file)
.with_context(|| format!("failed to read source file {}", file.display()))?;
let pattern = format!("{module_prefix}::{item_name}");
if source.contains(&pattern) {
return Ok(true);
}
}
}
Ok(false)
}
fn attributes_mention_name(attrs: &[syn::Attribute], item_name: &str) -> bool {
attrs
.iter()
.any(|attr| attribute_tokens_mention_name(attr, item_name))
}
fn attribute_tokens_mention_name(attr: &syn::Attribute, item_name: &str) -> bool {
fn token_tree_mentions_name(tree: &proc_macro2::TokenTree, item_name: &str) -> bool {
match tree {
proc_macro2::TokenTree::Group(group) => group
.stream()
.into_iter()
.any(|tree| token_tree_mentions_name(&tree, item_name)),
proc_macro2::TokenTree::Ident(ident) => ident == item_name,
proc_macro2::TokenTree::Literal(literal) => {
literal
.to_string()
.trim_matches('"')
.trim_matches('r')
.trim_matches('#')
== item_name
},
proc_macro2::TokenTree::Punct(_) => false,
}
}
attr.meta
.to_token_stream()
.into_iter()
.any(|tree| token_tree_mentions_name(&tree, item_name))
}
struct ItemSurfaceReferenceVisitor<'a> {
item_name: &'a str,
found: bool,
}
impl<'a> ItemSurfaceReferenceVisitor<'a> {
const fn new(item_name: &'a str) -> Self {
Self {
item_name,
found: false,
}
}
}
impl<'ast> Visit<'ast> for ItemSurfaceReferenceVisitor<'_> {
fn visit_path(&mut self, path: &'ast syn::Path) {
if self.found {
return;
}
if path
.segments
.last()
.is_some_and(|segment| segment.ident == self.item_name)
{
self.found = true;
return;
}
syn::visit::visit_path(self, path);
}
}
const fn allow_pub_crate_by_policy(
crate_kind: CrateKind,
module_location: ModuleLocation,
parent_is_public: bool,
) -> bool {
match (crate_kind, module_location) {
(CrateKind::Library, ModuleLocation::CrateRoot) => true,
(_, ModuleLocation::TopLevelPrivateModule) => !parent_is_public,
_ => false,
}
}
const fn forbidden_pub_crate_help(module_location: ModuleLocation) -> &'static str {
if matches!(
module_location,
ModuleLocation::CrateRoot | ModuleLocation::TopLevelPrivateModule
) {
"consider using just `pub` or removing `pub(crate)` entirely"
} else {
"consider using `pub(super)` or removing `pub(crate)` entirely"
}
}
fn suspicious_pub_note(crate_kind: CrateKind, kind_label: &str) -> String {
match crate_kind {
CrateKind::Library => {
format!("{kind_label} is not reachable from the crate's public API")
},
CrateKind::Binary => {
format!("{kind_label} is not used outside its parent module subtree")
},
}
}
#[derive(Debug)]
struct LineDisplay {
line: usize,
column: usize,
highlight_len: usize,
source_line: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParentFacadeExportStatus {
usage: ParentFacadeUsage,
fix_supported: bool,
visibility: ParentFacadeVisibility,
parent_path: PathBuf,
parent_rel_path: String,
parent_line: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParentFacadeUsage {
Unused,
UsedInsideParentSubtreeByRelativeImport,
UsedInsideParentSubtreeByRelativePath,
UsedInsideParentSubtreeByCrateImport,
UsedInsideParentSubtreeByCratePath,
UsedOutsideParentSubtree,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParentFacadeVisibility {
Public,
Super,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AllowanceReason {
Allowlist,
ParentIsPublic,
TopLevelPrivateModulePolicy,
ReachablePublicApi,
ParentFacadeUsedOutsideParent,
InternalParentFacadeBoundary,
ExposedByOtherCrateVisibleSignature,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SuspiciousPubAssessment {
Allowed(AllowanceReason),
ReviewInternalParentFacade {
related: Option<String>,
},
Warn {
fix_support: FixSupport,
related: Option<String>,
stale_parent_pub_use: Option<ParentFacadeExportStatus>,
},
}
#[derive(Debug, Clone)]
struct ParentBoundary {
boundary_file: PathBuf,
subtree_root: PathBuf,
module_path: Vec<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ParentFacadeExports {
explicit: Vec<String>,
fix_supported: bool,
visibility: Option<ParentFacadeVisibility>,
}
fn line_display(tcx: TyCtxt<'_>, file_path: &Path, span: Span) -> Result<LineDisplay> {
let source_map = tcx.sess.source_map();
let start = source_map.lookup_char_pos(span.lo());
let end = source_map.lookup_char_pos(span.hi());
let line = start.line;
let column = start.col_display + 1;
let highlight_len = if start.line == end.line {
(end.col_display.saturating_sub(start.col_display)).max(1)
} else {
1
};
let text = fs::read_to_string(file_path)
.with_context(|| format!("failed to read source file {}", file_path.display()))?;
let source_line = text
.lines()
.nth(line.saturating_sub(1))
.unwrap_or_default()
.to_string();
Ok(LineDisplay {
line,
column,
highlight_len,
source_line,
})
}
fn visibility_text(tcx: TyCtxt<'_>, vis_span: Span) -> Result<Option<String>> {
if vis_span.is_dummy() {
return Ok(None);
}
Ok(Some(
tcx.sess
.source_map()
.span_to_snippet(vis_span)
.map_err(|err| {
anyhow::anyhow!(
"failed to extract visibility snippet for span {vis_span:?}: {err:?}"
)
})?
.trim()
.to_string(),
))
}
fn real_file_path(tcx: TyCtxt<'_>, span: Span) -> Option<PathBuf> {
let source_map = tcx.sess.source_map();
let file = source_map.lookup_char_pos(span.lo()).file;
real_file_path_from_name(file.name.clone())
}
fn real_file_path_from_name(name: FileName) -> Option<PathBuf> {
match name {
FileName::Real(real) => real.local_path().map(Path::to_path_buf),
_ => None,
}
}
fn highlight_span(vis_span: Span, ident_span: Option<Span>) -> Span {
ident_span.map_or(vis_span, |ident_span| vis_span.to(ident_span))
}
const fn item_kind_label(kind: ItemKind<'_>) -> Option<&'static str> {
match kind {
ItemKind::Const(..) => Some("const"),
ItemKind::Enum(..) => Some("enum"),
ItemKind::Fn { .. } => Some("fn"),
ItemKind::Static(..) => Some("static"),
ItemKind::Struct(..) => Some("struct"),
ItemKind::Trait(..) | ItemKind::TraitAlias(..) => Some("trait"),
ItemKind::TyAlias(..) => Some("type"),
ItemKind::Union(..) => Some("union"),
ItemKind::Mod(..) => Some("mod"),
ItemKind::Use(..)
| ItemKind::ExternCrate(..)
| ItemKind::ForeignMod { .. }
| ItemKind::GlobalAsm { .. }
| ItemKind::Impl(..)
| ItemKind::Macro(..) => None,
}
}
const fn impl_item_kind_label(kind: ImplItemKind<'_>) -> &'static str {
match kind {
ImplItemKind::Const(..) => "const",
ImplItemKind::Fn(..) => "fn",
ImplItemKind::Type(..) => "type",
}
}
const fn foreign_item_kind_label(kind: ForeignItemKind<'_>) -> &'static str {
match kind {
ForeignItemKind::Fn(..) => "fn",
ForeignItemKind::Static(..) => "static",
ForeignItemKind::Type => "type",
}
}
fn is_boundary_file(src_root: &Path, root_module: &Path, file: &Path) -> bool {
let is_root_file = file == root_module;
let is_mod_rs = file.file_name().and_then(|name| name.to_str()) == Some("mod.rs");
let is_top_level_file = file
.strip_prefix(src_root)
.ok()
.is_some_and(|path| path.components().count() == 1);
is_root_file || is_mod_rs || is_top_level_file
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests should panic on unexpected values"
)]
#[allow(
clippy::unwrap_used,
reason = "tests should panic on unexpected values"
)]
#[allow(clippy::panic, reason = "tests should panic on unexpected values")]
mod tests {
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use super::CrateKind;
use super::DiagnosticBlockKind;
use super::DiagnosticCode;
use super::DriverSettings;
use super::FINDINGS_SCHEMA_VERSION;
use super::ModuleLocation;
use super::ParentFacadeExports;
use super::ParentFacadeVisibility;
use super::Severity;
use super::StoredFinding;
use super::StoredReport;
use super::allow_pub_crate_by_policy;
use super::analysis_source_root_for;
use super::cache_filename_for;
use super::cache_is_current_for;
use super::classify_diagnostic_block;
use super::config_relative_path;
use super::config_relative_path_for_settings;
use super::current_analysis_fingerprint;
use super::exported_names_from_parent_boundary;
use super::forbidden_pub_crate_help;
use super::is_progress_line;
use super::module_location;
use super::module_path_from_source_file;
use super::refresh_rustc_args;
use super::suspicious_pub_note;
use crate::config::LoadedConfig;
use crate::config::VisibilityConfig;
use crate::fix_support::FixSupport;
#[test]
fn allow_pub_crate_allows_library_crate_root_items() {
assert!(allow_pub_crate_by_policy(
CrateKind::Library,
ModuleLocation::CrateRoot,
true
));
}
#[test]
fn allow_pub_crate_allows_top_level_private_library_modules() {
assert!(allow_pub_crate_by_policy(
CrateKind::Library,
ModuleLocation::TopLevelPrivateModule,
false
));
}
#[test]
fn allow_pub_crate_rejects_nested_modules() {
assert!(!allow_pub_crate_by_policy(
CrateKind::Library,
ModuleLocation::NestedModule,
false
));
}
#[test]
fn allow_pub_crate_rejects_binary_crate_root_items() {
assert!(!allow_pub_crate_by_policy(
CrateKind::Binary,
ModuleLocation::CrateRoot,
true
));
}
#[test]
fn allow_pub_crate_allows_top_level_private_binary_modules() {
assert!(allow_pub_crate_by_policy(
CrateKind::Binary,
ModuleLocation::TopLevelPrivateModule,
false
));
}
#[test]
fn allow_pub_crate_rejects_binary_nested_modules() {
assert!(!allow_pub_crate_by_policy(
CrateKind::Binary,
ModuleLocation::NestedModule,
false
));
}
#[test]
fn module_location_handles_crate_root() {
assert_eq!(
module_location(true, false, false),
ModuleLocation::CrateRoot
);
}
#[test]
fn module_location_handles_top_level_private_module() {
assert_eq!(
module_location(false, true, false),
ModuleLocation::TopLevelPrivateModule
);
}
#[test]
fn module_location_handles_child_of_top_level_module() {
assert_eq!(
module_location(false, false, true),
ModuleLocation::TopLevelPrivateModule
);
}
#[test]
fn forbidden_pub_crate_help_handles_crate_root_items() {
assert_eq!(
forbidden_pub_crate_help(ModuleLocation::CrateRoot),
"consider using just `pub` or removing `pub(crate)` entirely"
);
}
#[test]
fn forbidden_pub_crate_help_handles_top_level_private_modules() {
assert_eq!(
forbidden_pub_crate_help(ModuleLocation::TopLevelPrivateModule),
"consider using just `pub` or removing `pub(crate)` entirely"
);
}
#[test]
fn forbidden_pub_crate_help_handles_nested_private_modules() {
assert_eq!(
forbidden_pub_crate_help(ModuleLocation::NestedModule),
"consider using `pub(super)` or removing `pub(crate)` entirely"
);
}
#[test]
fn suspicious_pub_note_uses_public_api_wording_for_libraries() {
assert_eq!(
suspicious_pub_note(CrateKind::Library, "struct"),
"struct is not reachable from the crate's public API"
);
}
#[test]
fn suspicious_pub_note_uses_subtree_wording_for_binaries() {
assert_eq!(
suspicious_pub_note(CrateKind::Binary, "function"),
"function is not used outside its parent module subtree"
);
}
#[test]
fn refresh_rustc_args_adds_mend_cfg() {
let args = refresh_rustc_args();
assert_eq!(args.first().map(String::as_str), Some("--"));
assert!(
args.get(1)
.is_some_and(|arg| arg.starts_with("--cfg=mend_refresh_"))
);
}
#[test]
fn config_relative_path_handles_nested_workspace_paths() -> anyhow::Result<()> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let workspace_root = std::env::temp_dir().join(format!("mend-config-root-test-{unique}"));
let file_path = workspace_root.join("mcp/src/brp_tools/tools/mod.rs");
let parent = file_path
.parent()
.ok_or_else(|| anyhow::anyhow!("test path must have a parent directory"))?;
fs::create_dir_all(parent)?;
fs::write(&file_path, "pub mod world_query;\n")?;
assert_eq!(
config_relative_path(&file_path, &workspace_root).as_deref(),
Some("mcp/src/brp_tools/tools/mod.rs")
);
Ok(())
}
#[test]
fn config_relative_path_for_settings_handles_package_relative_workspace_paths() {
let settings = DriverSettings {
config_root: PathBuf::from("/workspace/root"),
config: VisibilityConfig::default(),
config_fingerprint: "test".to_string(),
findings_dir: PathBuf::from("/workspace/root/target/mend-findings"),
package_root: PathBuf::from("/workspace/root/mcp"),
analysis_fingerprint: current_analysis_fingerprint(),
};
let file_path = PathBuf::from("src/brp_tools/tools/mod.rs");
assert_eq!(
config_relative_path_for_settings(&file_path, &settings).as_deref(),
Some("mcp/src/brp_tools/tools/mod.rs")
);
}
#[test]
fn config_relative_path_for_settings_handles_workspace_relative_paths() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let config_root = temp.path().join("workspace");
let package_root = config_root.join("mcp");
std::fs::create_dir_all(package_root.join("src/brp_tools/tools"))?;
std::fs::write(
package_root.join("src/brp_tools/tools/mod.rs"),
"pub mod child;\n",
)?;
let settings = DriverSettings {
config_root,
config: VisibilityConfig::default(),
config_fingerprint: "test".to_string(),
findings_dir: temp.path().join("workspace/target/mend-findings"),
package_root,
analysis_fingerprint: current_analysis_fingerprint(),
};
let file_path = PathBuf::from("mcp/src/brp_tools/tools/mod.rs");
assert_eq!(
config_relative_path_for_settings(&file_path, &settings).as_deref(),
Some("mcp/src/brp_tools/tools/mod.rs")
);
Ok(())
}
#[test]
fn cache_is_current_requires_matching_schema_version() -> anyhow::Result<()> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("mend-cache-test-{unique}"));
fs::create_dir_all(&temp_dir)?;
let package_root = Path::new("/tmp/example-crate");
let source_root = package_root.join("src");
let cache_path = temp_dir.join(cache_filename_for(package_root));
let stale = StoredReport {
version: FINDINGS_SCHEMA_VERSION - 1,
analysis_fingerprint: current_analysis_fingerprint(),
package_root: package_root.to_string_lossy().into_owned(),
config_fingerprint: "expected".to_string(),
findings: vec![StoredFinding {
severity: Severity::Warning,
code: DiagnosticCode::SuspiciousPub,
path: "src/lib.rs".to_string(),
line: 1,
column: 1,
highlight_len: 3,
source_line: "pub fn x() {}".to_string(),
item: None,
message: String::new(),
suggestion: None,
fix_support: FixSupport::None,
related: None,
}],
pub_use_fix_facts: Vec::new(),
saw_unused_import_warnings: false,
};
fs::write(&cache_path, serde_json::to_vec(&stale)?)?;
let loaded_config = LoadedConfig {
config: VisibilityConfig::default(),
diagnostics: crate::config::DiagnosticsConfig::default(),
root: PathBuf::from("/tmp"),
fingerprint: "expected".to_string(),
};
assert!(!cache_is_current_for(
&temp_dir,
package_root,
&source_root,
&loaded_config
));
fs::remove_dir_all(&temp_dir)?;
Ok(())
}
#[test]
fn cache_is_current_rejects_stale_cache_when_sources_changed() -> anyhow::Result<()> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("mend-cache-source-test-{unique}"));
let package_root = temp_dir.join("crate");
let src_dir = package_root.join("src");
fs::create_dir_all(&src_dir)?;
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)?;
fs::write(src_dir.join("lib.rs"), "pub fn demo() {}\n")?;
let findings_dir = temp_dir.join("findings");
fs::create_dir_all(&findings_dir)?;
let cache_path = findings_dir.join(cache_filename_for(&package_root));
let report = StoredReport {
version: FINDINGS_SCHEMA_VERSION,
analysis_fingerprint: current_analysis_fingerprint(),
package_root: package_root.to_string_lossy().into_owned(),
config_fingerprint: "expected".to_string(),
findings: Vec::new(),
pub_use_fix_facts: Vec::new(),
saw_unused_import_warnings: false,
};
fs::write(&cache_path, serde_json::to_vec(&report)?)?;
let loaded_config = LoadedConfig {
config: VisibilityConfig::default(),
diagnostics: crate::config::DiagnosticsConfig::default(),
root: temp_dir.clone(),
fingerprint: "expected".to_string(),
};
assert!(cache_is_current_for(
&findings_dir,
&package_root,
&src_dir,
&loaded_config
));
std::thread::sleep(std::time::Duration::from_secs(1));
fs::write(
src_dir.join("lib.rs"),
"pub fn demo() {}\npub fn newer() {}\n",
)?;
assert!(!cache_is_current_for(
&findings_dir,
&package_root,
&src_dir,
&loaded_config
));
fs::remove_dir_all(&temp_dir)?;
Ok(())
}
#[test]
fn cache_is_current_rejects_stale_cache_when_config_changes() -> anyhow::Result<()> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("mend-cache-config-test-{unique}"));
let package_root = temp_dir.join("crate");
let src_dir = package_root.join("src");
fs::create_dir_all(&src_dir)?;
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)?;
fs::write(src_dir.join("lib.rs"), "pub fn demo() {}\n")?;
let findings_dir = temp_dir.join("findings");
fs::create_dir_all(&findings_dir)?;
let cache_path = findings_dir.join(cache_filename_for(&package_root));
let report = StoredReport {
version: FINDINGS_SCHEMA_VERSION,
analysis_fingerprint: current_analysis_fingerprint(),
package_root: package_root.to_string_lossy().into_owned(),
config_fingerprint: "old-config".to_string(),
findings: Vec::new(),
pub_use_fix_facts: Vec::new(),
saw_unused_import_warnings: false,
};
fs::write(&cache_path, serde_json::to_vec(&report)?)?;
let loaded_config = LoadedConfig {
config: VisibilityConfig::default(),
diagnostics: crate::config::DiagnosticsConfig::default(),
root: temp_dir.clone(),
fingerprint: "new-config".to_string(),
};
assert!(!cache_is_current_for(
&findings_dir,
&package_root,
&src_dir,
&loaded_config
));
fs::remove_dir_all(&temp_dir)?;
Ok(())
}
#[test]
fn cache_is_current_rejects_stale_cache_when_analysis_fingerprint_changes() -> anyhow::Result<()>
{
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("mend-cache-analysis-test-{unique}"));
let package_root = temp_dir.join("crate");
let src_dir = package_root.join("src");
fs::create_dir_all(&src_dir)?;
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)?;
fs::write(src_dir.join("lib.rs"), "pub fn demo() {}\n")?;
let findings_dir = temp_dir.join("findings");
fs::create_dir_all(&findings_dir)?;
let cache_path = findings_dir.join(cache_filename_for(&package_root));
let report = StoredReport {
version: FINDINGS_SCHEMA_VERSION,
analysis_fingerprint: "old-analysis".to_string(),
package_root: package_root.to_string_lossy().into_owned(),
config_fingerprint: "expected".to_string(),
findings: Vec::new(),
pub_use_fix_facts: Vec::new(),
saw_unused_import_warnings: false,
};
fs::write(&cache_path, serde_json::to_vec(&report)?)?;
let loaded_config = LoadedConfig {
config: VisibilityConfig::default(),
diagnostics: crate::config::DiagnosticsConfig::default(),
root: temp_dir.clone(),
fingerprint: "expected".to_string(),
};
assert!(!cache_is_current_for(
&findings_dir,
&package_root,
&src_dir,
&loaded_config
));
fs::remove_dir_all(&temp_dir)?;
Ok(())
}
#[test]
fn cache_is_current_tracks_selected_example_source_root() -> anyhow::Result<()> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("mend-cache-example-test-{unique}"));
let package_root = temp_dir.join("crate");
let examples_dir = package_root.join("examples");
let src_dir = package_root.join("src");
fs::create_dir_all(&examples_dir)?;
fs::create_dir_all(&src_dir)?;
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)?;
fs::write(examples_dir.join("demo.rs"), "fn main() {}\n")?;
fs::write(src_dir.join("lib.rs"), "pub fn shared() {}\n")?;
let findings_dir = temp_dir.join("findings");
fs::create_dir_all(&findings_dir)?;
let cache_path = findings_dir.join(cache_filename_for(&package_root));
let report = StoredReport {
version: FINDINGS_SCHEMA_VERSION,
analysis_fingerprint: current_analysis_fingerprint(),
package_root: package_root.to_string_lossy().into_owned(),
config_fingerprint: "expected".to_string(),
findings: Vec::new(),
pub_use_fix_facts: Vec::new(),
saw_unused_import_warnings: false,
};
fs::write(&cache_path, serde_json::to_vec(&report)?)?;
let loaded_config = LoadedConfig {
config: VisibilityConfig::default(),
diagnostics: crate::config::DiagnosticsConfig::default(),
root: temp_dir.clone(),
fingerprint: "expected".to_string(),
};
assert!(cache_is_current_for(
&findings_dir,
&package_root,
&examples_dir,
&loaded_config
));
std::thread::sleep(std::time::Duration::from_secs(1));
fs::write(examples_dir.join("demo.rs"), "fn main() { shared(); }\n")?;
assert!(!cache_is_current_for(
&findings_dir,
&package_root,
&examples_dir,
&loaded_config
));
assert!(cache_is_current_for(
&findings_dir,
&package_root,
&src_dir,
&loaded_config
));
fs::remove_dir_all(&temp_dir)?;
Ok(())
}
#[test]
fn analysis_source_root_ignores_build_scripts() {
let package_root = Path::new("/tmp/example-crate");
assert_eq!(
analysis_source_root_for(&package_root.join("src/lib.rs"), package_root),
Some(package_root.join("src"))
);
assert_eq!(
analysis_source_root_for(&package_root.join("src/bin/demo.rs"), package_root),
Some(package_root.join("src/bin"))
);
assert_eq!(
analysis_source_root_for(&package_root.join("examples/demo.rs"), package_root),
Some(package_root.join("examples"))
);
assert_eq!(
analysis_source_root_for(&package_root.join("build.rs"), package_root),
None
);
}
#[test]
fn grouped_parent_pub_use_is_fix_supported() -> anyhow::Result<()> {
let exports = exported_names_from_parent_boundary(
"pub use report_writer::{ReportDefinition, ReportWriter};\n",
"report_writer",
"ReportDefinition",
)?;
assert_eq!(exports.explicit, vec!["ReportDefinition".to_string()]);
assert!(exports.fix_supported);
Ok(())
}
#[test]
fn multiline_grouped_parent_pub_use_is_fix_supported() -> anyhow::Result<()> {
let exports = exported_names_from_parent_boundary(
"pub use child::{\n Thing,\n Other,\n};\n",
"child",
"Thing",
)?;
assert_eq!(exports.explicit, vec!["Thing".to_string()]);
assert!(exports.fix_supported);
Ok(())
}
#[test]
fn module_path_from_source_file_treats_main_rs_as_crate_root() -> anyhow::Result<()> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("mend-main-root-test-{unique}"));
let src_dir = temp_dir.join("src");
fs::create_dir_all(&src_dir)?;
let main_rs = src_dir.join("main.rs");
fs::write(&main_rs, "fn main() {}\n")?;
assert_eq!(
module_path_from_source_file(&src_dir, &main_rs),
Some(Vec::new())
);
fs::remove_dir_all(&temp_dir)?;
Ok(())
}
#[test]
fn grouped_parent_pub_use_with_rename_is_manual_only() -> anyhow::Result<()> {
let exports = exported_names_from_parent_boundary(
"pub use child::{Thing as RenamedThing, Other};\n",
"child",
"Thing",
)?;
assert_eq!(
exports,
ParentFacadeExports {
explicit: vec!["RenamedThing".to_string()],
fix_supported: false,
visibility: Some(ParentFacadeVisibility::Public),
}
);
let exports = exported_names_from_parent_boundary(
"pub use child::{Thing as RenamedThing, Other};\n",
"child",
"Other",
)?;
assert_eq!(exports.explicit, vec!["Other".to_string()]);
assert!(exports.fix_supported);
Ok(())
}
#[test]
fn progress_line_with_embedded_warning_is_not_treated_as_progress() {
let line = " Building [ ] 0/1: fixture...warning: unused import: `child::SpawnStats`\n";
assert!(!is_progress_line(line));
}
#[test]
fn classify_suppresses_unused_import_when_warning_follows_progress_prefix() {
let block = vec![
" Building [ ] 0/1: fixture...warning: unused import: `child::SpawnStats`\n"
.to_string(),
" --> src/actor/mod.rs:2:9\n".to_string(),
" |\n".to_string(),
"2 | pub use child::SpawnStats;\n".to_string(),
" | ^^^^^^^^^^^^^^^^^\n".to_string(),
"\n".to_string(),
];
assert!(matches!(
classify_diagnostic_block(&block, false),
DiagnosticBlockKind::SuppressedUnusedImport
));
}
}