use crate::Xtask;
use anyhow::Context;
use clap::Parser;
use grep_regex::RegexMatcher;
use grep_regex::RegexMatcherBuilder;
use grep_searcher::BinaryDetection;
use grep_searcher::Searcher;
use grep_searcher::SearcherBuilder;
use grep_searcher::Sink;
use grep_searcher::SinkMatch;
use rayon::prelude::*;
use std::error;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Parser)]
#[clap(about = "Detect any unused dependencies in Cargo.toml files")]
#[clap(after_help = r#"NOTE:
False-positives can be suppressed by setting `package.metadata.xtask.unused-dep.ignored`
in the corresponding `Cargo.toml` file.
For example, "test-env-log" has implicit deps on both "env_logger" and "tracing-subscriber":
[package.metadata.xtask.unused-deps]
ignored = ["env_logger", "tracing-subscriber"]
"#)]
pub struct UnusedDeps {
#[clap(long)]
pub fix: bool,
}
impl Xtask for UnusedDeps {
fn run(self, ctx: crate::XtaskCtx) -> anyhow::Result<()> {
let entries = ignore::Walk::new(&ctx.root)
.filter_map(|entry| match entry {
Ok(entry) => {
if entry.file_name() == "Cargo.toml" {
Some(entry.into_path())
} else {
None
}
}
Err(err) => {
log::error!("error when walking over subdirectories: {}", err);
None
}
})
.collect::<Vec<_>>();
let mut results = entries
.par_iter()
.filter_map(|path| match analyze_crate(path) {
Ok(Some(analysis)) => Some((analysis, path)),
Ok(None) => {
log::debug!("{} is a virtual manifest for a workspace", path.display());
None
}
Err(err) => {
log::error!("error when handling {}: {}", path.display(), err);
None
}
})
.collect::<Vec<_>>();
results.sort_by(|a, b| a.1.cmp(b.1));
let mut workspace = analyze_workspace(&ctx.root)?;
let full_deps = workspace.deps.clone();
let mut found_something = false;
for (analysis, path) in results {
if !analysis.results.is_empty() {
found_something = true;
println!("{} -- {}:", analysis.package_name, path.display());
for result in &analysis.results {
match result {
DepResult::Unused(n) => println!("\t{} is unused", n),
DepResult::IgnoredButUsed(n) => {
println!("\t{} is ignored, but being used", n)
}
DepResult::IgnoredAndMissing(n) => {
println!("\t{} is ignored, but it's not even being depended on", n)
}
}
}
if self.fix {
let fixed =
remove_dependencies(&fs_err::read_to_string(path)?, &analysis.results)?;
fs_err::write(path, fixed).context("Cargo.toml write error")?;
}
}
workspace.deps.retain(|x| !analysis.deps.contains(x));
}
workspace.deps.sort();
workspace.ignored.sort();
if workspace.deps != workspace.ignored {
found_something = true;
let mut unused_deps = Vec::new();
println!("Workspace -- {}:", workspace.path.display());
for dep in &workspace.deps {
if !workspace.ignored.contains(dep) {
println!("\t{} is unused", dep);
unused_deps.push(DepResult::Unused(dep.clone()));
}
}
for ign in &workspace.ignored {
if !workspace.deps.contains(ign) {
if full_deps.contains(ign) {
println!("\t{} is ignored, but being used", ign);
unused_deps.push(DepResult::IgnoredButUsed(ign.clone()));
} else {
println!("\t{} is ignored, but it's not even being depended on", ign);
unused_deps.push(DepResult::IgnoredAndMissing(ign.clone()));
}
}
}
if self.fix {
let fixed =
remove_dependencies(&fs_err::read_to_string(&workspace.path)?, &unused_deps)?;
fs_err::write(&workspace.path, fixed).context("Cargo.toml write error")?;
}
}
if found_something && !self.fix {
Err(anyhow::anyhow!("found dependency issues"))
} else {
Ok(())
}
}
}
fn remove_dependencies(manifest: &str, analysis_results: &[DepResult]) -> anyhow::Result<String> {
let mut manifest = toml_edit::DocumentMut::from_str(manifest)?;
let mut unused_deps = Vec::new();
let mut ignored_and_shouldnt_be = Vec::new();
for res in analysis_results {
match res {
DepResult::Unused(n) => unused_deps.push(n),
DepResult::IgnoredButUsed(n) => ignored_and_shouldnt_be.push(n),
DepResult::IgnoredAndMissing(n) => ignored_and_shouldnt_be.push(n),
}
}
let mut features_table = None;
let mut dep_tables = Vec::new();
let mut ignored_array = None;
for (k, v) in manifest.iter_mut() {
let v = match v {
v if v.is_table_like() => v.as_table_like_mut().unwrap(),
_ => continue,
};
match k.get() {
"dependencies" | "build-dependencies" | "dev-dependencies" => dep_tables.push(v),
"target" => {
let flattened = v.iter_mut().flat_map(|(_, v)| {
v.as_table_like_mut()
.expect("conforms to cargo schema")
.iter_mut()
});
for (k, v) in flattened {
let v = match v {
v if v.is_table_like() => v.as_table_like_mut().unwrap(),
_ => continue,
};
match k.get() {
"dependencies" | "build-dependencies" | "dev-dependencies" => {
dep_tables.push(v)
}
_ => {}
}
}
}
"workspace" => {
for (k2, v2) in v.iter_mut() {
let v2 = match v2 {
v2 if v2.is_table_like() => v2.as_table_like_mut().unwrap(),
_ => continue,
};
match k2.get() {
"dependencies" => dep_tables.push(v2),
"metadata" => {
if v2
.get("xtask")
.and_then(|x| x.get("unused-deps"))
.and_then(|u| u.get("ignored"))
.is_some()
{
ignored_array = v2
.get_mut("metadata")
.unwrap()
.get_mut("xtask")
.unwrap()
.get_mut("unused-deps")
.unwrap()
.get_mut("ignored")
.unwrap()
.as_array_mut();
}
}
_ => {}
}
}
}
"package" => {
if v.get("metadata")
.and_then(|m| m.get("xtask"))
.and_then(|x| x.get("unused-deps"))
.and_then(|u| u.get("ignored"))
.is_some()
{
ignored_array = v
.get_mut("metadata")
.unwrap()
.get_mut("xtask")
.unwrap()
.get_mut("unused-deps")
.unwrap()
.get_mut("ignored")
.unwrap()
.as_array_mut();
}
}
"features" => features_table = Some(v),
_ => {}
}
}
for i in ignored_and_shouldnt_be {
let ignored_array = ignored_array
.as_mut()
.expect("must have an ignored array for IgnoredButUsed results to appear");
let index = ignored_array
.iter()
.position(|v| v.as_str() == Some(i))
.expect("must find items that were found in previous pass");
ignored_array.remove(index);
}
if let Some(features_table) = features_table {
for (_feature_name, feature_deps) in features_table.iter_mut() {
let mut to_remove = Vec::new();
let feature_deps = feature_deps
.as_array_mut()
.expect("feature dependencies must be an array");
for index in 0..feature_deps.len() {
let feature_dep_name = feature_deps
.get(index)
.unwrap()
.as_str()
.expect("feature dependencies must be strings");
let feature_dep_name = feature_dep_name
.strip_prefix("dep:")
.unwrap_or(feature_dep_name);
for unused in &unused_deps {
if feature_dep_name.starts_with(&**unused)
&& (feature_dep_name.len() == unused.len()
|| matches!(feature_dep_name.as_bytes()[unused.len()], b'/' | b'?'))
{
to_remove.push(index);
}
}
}
for i in to_remove.into_iter().rev() {
feature_deps.remove(i);
}
}
}
for dep_table in dep_tables {
unused_deps.retain(|dep| dep_table.remove(dep).is_none());
}
assert!(unused_deps.is_empty());
let serialized = manifest.to_string();
Ok(serialized)
}
mod meta {
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize)]
pub struct PackageMetadata {
pub xtask: Option<Xtask>,
}
#[derive(Serialize, Deserialize)]
pub struct Xtask {
#[serde(rename = "unused-deps")]
pub unused_deps: Option<Ignored>,
}
#[derive(Serialize, Deserialize)]
pub struct Ignored {
pub ignored: Vec<String>,
}
}
type Manifest = cargo_toml::Manifest<meta::PackageMetadata>;
struct PackageAnalysis {
pub package_name: String,
pub results: Vec<DepResult>,
pub deps: Vec<String>,
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum DepResult {
Unused(String),
IgnoredButUsed(String),
IgnoredAndMissing(String),
}
struct WorkspaceAnalysis {
pub path: PathBuf,
pub deps: Vec<String>,
pub ignored: Vec<String>,
}
fn make_regexp(name: &str) -> String {
format!(r#"use (::)?{name}(::|;| as)|\b{name}::|extern crate {name}( |;)"#)
}
fn collect_paths(dir_path: &Path, manifest: &Manifest) -> Vec<PathBuf> {
let mut root_paths = Vec::new();
if let Some(path) = manifest.lib.as_ref().and_then(|lib| lib.path.as_ref()) {
assert!(
path.ends_with(".rs"),
"paths provided by cargo_toml are to Rust files"
);
let mut path_buf = PathBuf::from(path);
path_buf.pop();
root_paths.push(path_buf);
}
for product in (manifest.bin.iter())
.chain(manifest.bench.iter())
.chain(manifest.test.iter())
.chain(manifest.example.iter())
{
if let Some(ref path) = product.path {
assert!(
path.ends_with(".rs"),
"paths provided by cargo_toml are to Rust files"
);
let mut path_buf = PathBuf::from(path);
path_buf.pop();
root_paths.push(path_buf);
}
}
log::trace!("found root paths: {:?}", root_paths);
if root_paths.is_empty() {
root_paths.push(PathBuf::from("src"));
log::trace!("adding src/ since paths was empty");
}
let mut paths: Vec<PathBuf> = root_paths
.iter()
.flat_map(|root| ignore::Walk::new(dir_path.join(root)))
.filter_map(|result| {
let dir_entry = match result {
Ok(dir_entry) => dir_entry,
Err(err) => {
log::error!("{}", err);
return None;
}
};
if !dir_entry.file_type().unwrap().is_file() {
return None;
}
if dir_entry
.path()
.extension()
.is_none_or(|ext| ext.to_str() != Some("rs"))
{
return None;
}
Some(dir_entry.path().to_owned())
})
.collect();
let build_rs = dir_path.join("build.rs");
if build_rs.exists() {
paths.push(build_rs);
}
log::trace!("found transitive paths: {:?}", paths);
paths
}
struct Search {
matcher: RegexMatcher,
searcher: Searcher,
sink: StopAfterFirstMatch,
}
impl Search {
fn new(crate_name: &str) -> anyhow::Result<Self> {
let snaked = crate_name.replace('-', "_");
let pattern = make_regexp(&snaked);
let matcher = RegexMatcherBuilder::new()
.multi_line(true)
.build(&pattern)?;
let searcher = SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00'))
.line_number(false)
.build();
let sink = StopAfterFirstMatch::new();
Ok(Self {
matcher,
searcher,
sink,
})
}
fn search_path(&mut self, path: &Path) -> anyhow::Result<bool> {
self.searcher
.search_path(&self.matcher, path, &mut self.sink)
.map_err(|err| anyhow::anyhow!("when searching: {}", err))
.map(|_| self.sink.found)
}
}
fn analyze_workspace(root: &Path) -> anyhow::Result<WorkspaceAnalysis> {
let path = root.join("Cargo.toml");
let manifest = Manifest::from_path_with_metadata(&path)?;
let workspace = manifest
.workspace
.expect("workspace manifest must have a workspace section");
let deps = workspace.dependencies.into_keys().collect();
let ignored = workspace
.metadata
.and_then(|meta| meta.xtask.and_then(|x| x.unused_deps.map(|u| u.ignored)))
.unwrap_or_default();
Ok(WorkspaceAnalysis {
deps,
path,
ignored,
})
}
fn analyze_crate(manifest_path: &Path) -> anyhow::Result<Option<PackageAnalysis>> {
let mut dir_path = manifest_path.to_path_buf();
dir_path.pop();
log::trace!("trying to open {}...", manifest_path.display());
let mut manifest = Manifest::from_path_with_metadata(manifest_path)?;
let package_name = match manifest.package {
Some(ref package) => package.name.clone(),
None => return Ok(None),
};
log::debug!("handling {} ({})", package_name, dir_path.display());
manifest.complete_from_path(manifest_path)?;
let paths = collect_paths(&dir_path, &manifest);
let mut deps = Vec::new();
deps.extend(manifest.dependencies.keys().cloned());
deps.extend(manifest.build_dependencies.keys().cloned());
deps.extend(manifest.dev_dependencies.keys().cloned());
for target in manifest.target.iter() {
deps.extend(target.1.dependencies.keys().cloned());
deps.extend(target.1.build_dependencies.keys().cloned());
deps.extend(target.1.dev_dependencies.keys().cloned());
}
let ignored = if let Some(unused_deps) = manifest
.package
.and_then(|package| package.metadata)
.and_then(|meta| meta.xtask.and_then(|x| x.unused_deps))
{
unused_deps.ignored
} else {
Vec::new()
};
let mut results = deps
.par_iter()
.filter_map(|name| {
let mut search = Search::new(name).expect("constructing grep context");
let mut found_once = false;
for path in &paths {
log::trace!("looking for {} in {}", name, path.to_string_lossy());
match search.search_path(path) {
Ok(true) => {
found_once = true;
break;
}
Ok(false) => {}
Err(err) => {
log::error!("{}: {}", path.display(), err);
}
};
}
let ignored = ignored.contains(name);
match (found_once, ignored) {
(true, true) => Some(DepResult::IgnoredButUsed(name.into())),
(true, false) => None,
(false, true) => None,
(false, false) => Some(DepResult::Unused(name.into())),
}
})
.collect::<Vec<_>>();
for i in &ignored {
if !deps.contains(i) {
results.push(DepResult::IgnoredAndMissing(i.clone()));
}
}
results.sort();
Ok(Some(PackageAnalysis {
package_name,
results,
deps,
}))
}
struct StopAfterFirstMatch {
found: bool,
}
impl StopAfterFirstMatch {
fn new() -> Self {
Self { found: false }
}
}
impl Sink for StopAfterFirstMatch {
type Error = Box<dyn error::Error>;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
let mat = String::from_utf8(mat.bytes().to_vec())?;
let mat = mat.trim();
if mat.starts_with("//") || mat.starts_with("//!") {
return Ok(true);
}
self.found = true;
Ok(false)
}
}