mod import_collector;
use std::{
collections::{HashMap, HashSet},
env, fs,
path::{Path, PathBuf},
process::ExitCode,
str::FromStr,
};
use anyhow::{Context, Result};
use bpaf::Bpaf;
use cargo_metadata::{CargoOpt, Metadata, MetadataCommand, Package};
use cargo_util_schemas::core::PackageIdSpec;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use walkdir::{DirEntry, WalkDir};
use crate::import_collector::collect_imports;
const VERSION: &str = match option_env!("SHEAR_VERSION") {
Some(v) => v,
None => "dev",
};
#[derive(Debug, Clone, Bpaf)]
#[bpaf(options("shear"), version(VERSION))]
pub struct CargoShearOptions {
#[bpaf(long)]
fix: bool,
#[bpaf(positional("PATH"), fallback_with(default_path))]
path: PathBuf,
}
fn default_path() -> Result<PathBuf> {
env::current_dir().map_err(|err| anyhow::anyhow!(err))
}
pub struct CargoShear {
options: CargoShearOptions,
unused_dependencies: usize,
}
type Deps = HashSet<String>;
impl CargoShear {
#[must_use]
pub const fn new(options: CargoShearOptions) -> Self {
Self { options, unused_dependencies: 0 }
}
#[must_use]
pub fn run(mut self) -> ExitCode {
println!("Analyzing {}", self.options.path.to_string_lossy());
println!();
match self.shear() {
Ok(()) => {
let has_deps = self.unused_dependencies > 0;
if has_deps {
println!(
"\n\
If you believe cargo-shear has detected an unused dependency incorrectly,\n\
you can add the dependency to the list of dependencies to ignore in the\n\
`[package.metadata.cargo-shear]` section of the appropriate Cargo.toml.\n\
\n\
For example:\n\
\n\
[package.metadata.cargo-shear]\n\
ignored = [\"crate-name\"]"
);
} else {
println!("No unused dependencies!");
}
ExitCode::from(u8::from(has_deps))
}
Err(err) => {
println!("{err}");
ExitCode::from(2)
}
}
}
fn shear(&mut self) -> Result<()> {
let metadata = MetadataCommand::new()
.features(CargoOpt::AllFeatures)
.current_dir(&self.options.path)
.exec()?;
let mut package_dependencies = HashSet::new();
for package in metadata.workspace_packages() {
let deps = self.shear_package(&metadata, package)?;
package_dependencies.extend(deps);
}
self.shear_workspace(&metadata, &package_dependencies)
}
fn shear_workspace(
&mut self,
workspace_metadata: &Metadata,
all_pkg_deps: &Deps,
) -> Result<()> {
if workspace_metadata.workspace_packages().len() <= 1 {
return Ok(());
}
let metadata_path = workspace_metadata.workspace_root.as_std_path();
let cargo_toml_path = metadata_path.join("Cargo.toml");
let metadata = cargo_toml::Manifest::from_path(&cargo_toml_path)?;
let Some(workspace) = &metadata.workspace else { return Ok(()) };
let ignored_package_names =
Self::get_ignored_package_names(&workspace_metadata.workspace_metadata);
let workspace_deps = workspace
.dependencies
.iter()
.map(|(key, dependency)| {
dependency
.detail()
.and_then(|detail| detail.package.as_ref())
.unwrap_or(key)
.clone()
})
.filter(|name| !ignored_package_names.contains(name.as_str()))
.collect::<HashSet<String>>();
let unused_deps = workspace_deps.difference(all_pkg_deps).cloned().collect::<Vec<_>>();
if unused_deps.is_empty() {
return Ok(());
}
let path = cargo_toml_path
.strip_prefix(env::current_dir()?)
.unwrap_or(&cargo_toml_path)
.to_string_lossy();
println!("root -- {path}:",);
for unused_dep in &unused_deps {
println!(" {unused_dep}");
}
println!();
self.try_fix_package(&cargo_toml_path, &unused_deps)?;
self.unused_dependencies += unused_deps.len();
Ok(())
}
fn shear_package(&mut self, metadata: &Metadata, package: &Package) -> Result<Deps> {
let workspace_root = metadata.workspace_root.as_std_path();
let dir = package
.manifest_path
.parent()
.ok_or_else(|| anyhow::anyhow!("failed to get parent path {}", &package.manifest_path))?
.as_std_path();
let relative_path = package
.manifest_path
.as_std_path()
.strip_prefix(workspace_root)
.unwrap_or(dir)
.to_string_lossy();
let ignored_package_names = Self::get_ignored_package_names(&package.metadata);
let this_package = metadata
.resolve
.as_ref()
.context("`cargo_metadata::MetadataCommand::no_deps` should not be called.")?
.nodes
.iter()
.find(|node| node.id == package.id)
.context("package should exist")?;
let package_dependency_names_map = this_package
.deps .iter()
.map(|node_dep| {
Self::parse_package_id(&node_dep.pkg.repr)
.map(|package_name| (node_dep.name.clone(), package_name))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter(|(_, name)| !ignored_package_names.contains(name.as_str()))
.collect::<HashMap<String, String>>();
let module_names_from_package_deps =
package_dependency_names_map.keys().cloned().collect::<HashSet<_>>();
let package_dependency_names =
package_dependency_names_map.values().cloned().collect::<HashSet<_>>();
let module_names_from_rust_files = Self::get_package_dependencies_from_rust_files(package)?;
let unused_module_names = module_names_from_package_deps
.difference(&module_names_from_rust_files)
.collect::<Vec<_>>();
if unused_module_names.is_empty() {
return Ok(package_dependency_names);
}
let unused_dependency_names = unused_module_names
.into_iter()
.map(|name| package_dependency_names_map[name].clone())
.collect::<Vec<_>>();
self.try_fix_package(package.manifest_path.as_std_path(), &unused_dependency_names)?;
if !unused_dependency_names.is_empty() {
self.unused_dependencies += unused_dependency_names.len();
println!("{} -- {relative_path}:", package.name);
for unused_dep in &unused_dependency_names {
println!(" {unused_dep}");
}
println!();
}
let package_dependency_names = package_dependency_names
.difference(&HashSet::from_iter(unused_dependency_names))
.cloned()
.collect::<Deps>();
Ok(package_dependency_names)
}
fn parse_package_id(s: &str) -> Result<String> {
if s.contains(' ') {
s.split(' ')
.next()
.map(ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("{s} should have a space"))
} else {
PackageIdSpec::parse(s)
.map(|id| id.name().to_string())
.map_err(|err| anyhow::anyhow!(err))
}
}
fn get_ignored_package_names(value: &serde_json::Value) -> HashSet<&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::<HashSet<_>>())
.unwrap_or_default()
}
fn get_package_dependencies_from_rust_files(package: &Package) -> Result<Deps> {
Ok(Self::get_package_rust_files(package)
.par_iter()
.map(|path| Self::process_rust_source(path))
.collect::<Result<Vec<Deps>>>()?
.into_iter()
.fold(HashSet::new(), |a, b| a.union(&b).cloned().collect()))
}
fn get_package_rust_files(package: &Package) -> Vec<PathBuf> {
package
.targets
.iter()
.flat_map(|target| {
if target.kind.iter().any(|s| s == "custom-build") {
vec![target.src_path.clone().into_std_path_buf()]
} else {
let target_dir = target.src_path.parent().unwrap_or_else(|| {
panic!("failed to get parentp path {}", &target.src_path)
});
WalkDir::new(target_dir)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|ext| ext == "rs"))
.map(DirEntry::into_path)
.collect::<Vec<_>>()
}
})
.collect()
}
fn process_rust_source(path: &Path) -> Result<Deps> {
let source_text = fs::read_to_string(path)?;
let imports = collect_imports(&source_text)?;
Ok(imports)
}
fn try_fix_package(&self, cargo_toml_path: &Path, unused_dep_names: &[String]) -> Result<()> {
if !self.options.fix {
return Ok(());
}
let manifest = fs::read_to_string(cargo_toml_path)?;
let mut manifest = toml_edit::DocumentMut::from_str(&manifest)?;
if let Some(dependencies) = manifest
.get_mut("workspace")
.and_then(|item| item.as_table_mut())
.and_then(|table| table.get_mut("dependencies"))
.and_then(|item| item.as_table_mut())
{
for k in unused_dep_names {
dependencies.remove(k);
}
}
for table_name in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(dependencies) =
manifest.get_mut(table_name).and_then(|item| item.as_table_mut())
{
for k in unused_dep_names {
dependencies.remove(k);
}
}
}
let serialized = manifest.to_string();
fs::write(cargo_toml_path, serialized)?;
Ok(())
}
}