use std::{
env,
ffi::OsString,
fmt::Write,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Result, anyhow};
use cargo_metadata::{Package, TargetKind};
use cargo_toml::Manifest;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use rustc_hash::FxHashSet;
use walkdir::{DirEntry, WalkDir};
use crate::import_collector::collect_imports;
pub struct DependencyAnalyzer {
expand_macros: bool,
}
impl DependencyAnalyzer {
pub const fn new(expand_macros: bool) -> Self {
Self { expand_macros }
}
pub fn analyze_package(
&self,
package: &Package,
manifest: &Manifest,
) -> Result<FxHashSet<String>> {
let mut imports = if self.expand_macros {
Self::analyze_with_expansion(package)?
} else {
Self::analyze_from_files(package)?
};
let features = Self::analyze_features(manifest);
imports.extend(features);
Ok(imports)
}
fn analyze_from_files(package: &Package) -> Result<FxHashSet<String>> {
let rust_files = Self::get_package_rust_files(package);
let deps_vec: Vec<FxHashSet<String>> = rust_files
.par_iter()
.map(|path| Self::process_rust_source(path))
.collect::<Result<Vec<_>>>()?;
Ok(deps_vec.into_iter().fold(FxHashSet::default(), |a, b| a.union(&b).cloned().collect()))
}
fn analyze_with_expansion(package: &Package) -> Result<FxHashSet<String>> {
let mut combined_imports = Self::analyze_from_files(package)?;
for target in &package.targets {
let target_arg =
match target.kind.first().ok_or_else(|| anyhow!("Failed to get target kind"))? {
TargetKind::CustomBuild => continue,
TargetKind::Bin => format!("--bin={}", target.name),
TargetKind::Example => format!("--example={}", target.name),
TargetKind::Test => format!("--test={}", target.name),
TargetKind::Bench => format!("--bench={}", target.name),
TargetKind::CDyLib
| TargetKind::DyLib
| TargetKind::Lib
| TargetKind::ProcMacro
| TargetKind::RLib
| TargetKind::StaticLib
| TargetKind::Unknown(_)
| _ => "--lib".to_owned(),
};
let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
let mut cmd = Command::new(cargo);
cmd.arg("rustc")
.arg(&target_arg)
.arg("--all-features")
.arg("--profile=check")
.arg("--color=never")
.arg("--")
.arg("-Zunpretty=expanded")
.current_dir(package.manifest_path.parent().ok_or_else(|| {
anyhow!("Failed to get parent path: {}", package.manifest_path)
})?);
let output = cmd.output()?;
if !output.status.success() {
return Err(anyhow!(
"Cargo expand failed for {}: {}",
target.name,
String::from_utf8_lossy(&output.stderr)
));
}
let output_str = String::from_utf8(output.stdout)?;
if output_str.is_empty() {
return Err(anyhow!(
"Cargo expand failed for {}: Empty output from cargo expand",
target.name
));
}
let imports = collect_imports(&output_str).map_err(|err| {
let location = err.span().start();
let snippet = Self::extract_code_snippet(&output_str, location.line);
anyhow!(
"Syntax error in {} at line {}:{}:\n{err}\n{snippet}",
target.name,
location.line,
location.column
)
})?;
combined_imports.extend(imports);
}
Ok(combined_imports)
}
fn get_package_rust_files(package: &Package) -> Vec<PathBuf> {
package
.targets
.iter()
.flat_map(|target| {
if target.kind.contains(&TargetKind::CustomBuild) {
vec![target.src_path.clone().into_std_path_buf()]
} else {
let target_dir = target.src_path.parent().unwrap_or_else(|| {
panic!("Failed to get parent path {}", &target.src_path)
});
WalkDir::new(target_dir)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().is_some_and(|ext| ext == "rs")
})
.map(DirEntry::into_path)
.collect::<Vec<_>>()
}
})
.collect()
}
fn process_rust_source(path: &Path) -> Result<FxHashSet<String>> {
let source_text = std::fs::read_to_string(path)?;
collect_imports(&source_text).map_err(|err| {
let location = err.span().start();
let snippet = Self::extract_code_snippet(&source_text, location.line);
anyhow!(
"Syntax error in {} at line {}:{}:\n{err}\n{snippet}",
path.display(),
location.line,
location.column
)
})
}
fn extract_code_snippet(source: &str, location: usize) -> String {
let lines: Vec<&str> = source.lines().collect();
let total = lines.len();
if location == 0 || location > total {
return String::new();
}
let start = location.saturating_sub(4);
let end = (location + 3).min(total);
let mut snippet = String::from("\n");
for (index, line) in lines.iter().enumerate().skip(start).take(end - start) {
let line_num = index + 1;
let marker = if line_num == location { ">" } else { " " };
let _ = writeln!(snippet, "{marker} {line_num:4} | {line}");
}
snippet
}
fn analyze_features(manifest: &Manifest) -> FxHashSet<String> {
let mut imports = FxHashSet::default();
for features in manifest.features.values() {
for feature in features {
if let Some(dep) = feature.strip_prefix("dep:") {
let import = dep.replace('-', "_");
imports.insert(import);
continue;
}
if let Some((dep, _)) = feature.split_once('/') {
let dep = dep.trim_end_matches('?');
let import = dep.replace('-', "_");
imports.insert(import);
}
}
}
for (dep, details) in &manifest.dependencies {
if details.optional() {
let import = dep.replace('-', "_");
imports.insert(import);
}
}
imports
}
pub fn get_ignored_dependency_keys(value: &serde_json::Value) -> FxHashSet<&str> {
value
.as_object()
.and_then(|object| object.get("cargo-shear"))
.and_then(|object| object.get("ignored"))
.and_then(|ignored| ignored.as_array())
.map(|ignored| {
ignored.iter().filter_map(|item| item.as_str()).collect::<FxHashSet<_>>()
})
.unwrap_or_default()
}
}