use cargo_metadata::CargoOpt;
use grep::{
matcher::LineTerminator,
regex::{RegexMatcher, RegexMatcherBuilder},
searcher::{self, BinaryDetection, Searcher, SearcherBuilder, Sink},
};
use log::{debug, trace, warn};
use meta::MetadataFields;
use rayon::prelude::*;
use std::{
collections::{BTreeMap, HashSet},
error::{self, Error},
path::{Path, PathBuf},
};
use walkdir::WalkDir;
#[cfg(test)]
use crate::TOP_LEVEL;
use crate::UseCargoMetadata;
use self::meta::PackageMetadata;
mod meta {
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct PackageMetadata {
#[serde(rename = "cargo-machete")]
pub cargo_machete: Option<MetadataFields>,
}
#[derive(Serialize, Deserialize)]
pub struct MetadataFields {
#[serde(default)]
pub ignored: Vec<String>,
#[serde(default)]
pub renamed: BTreeMap<Box<str>, Box<str>>,
}
}
pub(crate) struct PackageAnalysis {
metadata: Option<cargo_metadata::Metadata>,
pub manifest: cargo_toml::Manifest<meta::PackageMetadata>,
pub package_name: String,
pub unused: Vec<String>,
pub ignored_used: Vec<String>,
}
impl PackageAnalysis {
fn new(
package_name: String,
cargo_path: &Path,
manifest: cargo_toml::Manifest<meta::PackageMetadata>,
with_cargo_metadata: bool,
) -> anyhow::Result<Self> {
let metadata = if with_cargo_metadata {
Some(
cargo_metadata::MetadataCommand::new()
.features(CargoOpt::AllFeatures)
.manifest_path(cargo_path)
.exec()?,
)
} else {
None
};
Ok(Self {
metadata,
manifest,
package_name,
unused: Vec::default(),
ignored_used: Vec::default(),
})
}
}
fn make_line_regexp(name: &str) -> String {
format!(
r#"use (::)?(?i){name}(?-i)(::|;| as)|(?:[^:]|^|\W::)\b(?i){name}(?-i)::|extern crate (?i){name}(?-i)( |;)"#
)
}
fn make_multiline_regexp(name: &str) -> String {
let sub_modules_match = r#"(?:::\w+)*(?:::\*|\s+as\s+\w+|::\{(?:[^{}]*(?:\{(?:[^{}]*(?:\{(?:[^{}]*(?:\{[^{}]*\})?[^{}]*)*\})?[^{}]*)*\})?[^{}]*)*\})?"#;
format!(
r#"use \{{\s*(?:(::)?\w+{sub_modules_match}\s*,\s*)*(::)?{name}{sub_modules_match}\s*(?:\s*,\s*(::)?\w+{sub_modules_match})*\s*,?\s*\}};"#
)
}
fn collect_paths(dir_path: &Path, analysis: &PackageAnalysis) -> Vec<PathBuf> {
let manifest = &analysis.manifest;
let mut root_paths: HashSet<PathBuf> = manifest
.lib
.iter()
.chain(manifest.bin.iter())
.chain(manifest.bench.iter())
.chain(manifest.test.iter())
.chain(manifest.example.iter())
.filter_map(|p| {
let path_str = p.path.as_ref().filter(|s| s.ends_with(".rs"))?;
PathBuf::from(path_str).parent().map(PathBuf::from)
})
.collect();
trace!("found root paths: {root_paths:?}");
if root_paths.is_empty() {
root_paths.insert(PathBuf::from("src"));
trace!("adding src/ since paths was empty");
}
let paths = root_paths
.iter()
.flat_map(|root| WalkDir::new(dir_path.join(root)).into_iter())
.filter_map(|result| {
result
.inspect_err(|err| eprintln!("{err}"))
.ok()
.and_then(|entry| {
(entry.file_type().is_file()
&& entry.path().extension().is_some_and(|ext| ext == "rs"))
.then(|| entry.path().to_owned())
})
})
.collect();
trace!("found transitive paths: {paths:?}");
paths
}
struct Search {
line_matcher: RegexMatcher,
line_searcher: Searcher,
multiline_matcher: RegexMatcher,
multiline_searcher: Searcher,
sink: StopAfterFirstMatch,
}
impl Search {
fn new(crate_name: &str) -> anyhow::Result<Self> {
assert!(!crate_name.contains('-'));
let line_matcher = RegexMatcher::new_line_matcher(&make_line_regexp(crate_name))?;
let line_searcher = SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00'))
.line_terminator(LineTerminator::byte(b'\n'))
.line_number(false)
.build();
let multiline_matcher = RegexMatcherBuilder::new()
.multi_line(true)
.build(&make_multiline_regexp(crate_name))?;
let multiline_searcher = SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00'))
.multi_line(true)
.line_number(false)
.build();
debug_assert!(multiline_searcher.multi_line_with_matcher(&multiline_matcher));
let sink = StopAfterFirstMatch::new();
Ok(Self {
line_matcher,
line_searcher,
multiline_matcher,
multiline_searcher,
sink,
})
}
fn try_singleline_then_multiline<
F: FnMut(&mut Searcher, &RegexMatcher, &mut StopAfterFirstMatch) -> Result<(), Box<dyn Error>>,
>(
&mut self,
mut func: F,
) -> anyhow::Result<bool> {
match func(&mut self.line_searcher, &self.line_matcher, &mut self.sink) {
Ok(()) => {
if self.sink.found {
return Ok(true);
}
func(
&mut self.multiline_searcher,
&self.multiline_matcher,
&mut self.sink,
)
.map_err(|err| anyhow::anyhow!("when searching with complex pattern: {err}"))
.map(|()| self.sink.found)
}
Err(err) => anyhow::bail!("when searching with line pattern: {err}"),
}
}
fn search_path(&mut self, path: &Path) -> anyhow::Result<bool> {
self.try_singleline_then_multiline(|searcher, matcher, sink| {
searcher.search_path(matcher, path, sink)
})
}
#[cfg(test)]
fn search_string(&mut self, s: &str) -> anyhow::Result<bool> {
self.try_singleline_then_multiline(|searcher, matcher, sink| {
searcher.search_reader(matcher, s.as_bytes(), sink)
})
}
}
fn get_full_manifest(
dir_path: &Path,
manifest_path: &Path,
) -> anyhow::Result<(
cargo_toml::Manifest<PackageMetadata>,
Option<meta::MetadataFields>,
)> {
let cargo_toml_content = std::fs::read(manifest_path)?;
let mut manifest =
cargo_toml::Manifest::<PackageMetadata>::from_slice_with_metadata(&cargo_toml_content)?;
let mut ws_manifest_and_path = None;
let mut dir_path = std::fs::canonicalize(dir_path).unwrap_or_else(|err| {
warn!("error when canonicalizing dir_path: {err}");
dir_path.to_owned()
});
while dir_path.pop() {
let workspace_cargo_path = dir_path.join("Cargo.toml");
if let Ok(workspace_manifest) =
cargo_toml::Manifest::<PackageMetadata>::from_path_with_metadata(&workspace_cargo_path)
&& workspace_manifest.workspace.is_some()
{
ws_manifest_and_path = Some((workspace_manifest, workspace_cargo_path));
break;
}
}
manifest.complete_from_path_and_workspace(
manifest_path,
ws_manifest_and_path.as_ref().map(|(m, p)| (m, p.as_path())),
)?;
Ok((
manifest,
ws_manifest_and_path
.and_then(|(manifest, _path)| manifest.workspace?.metadata?.cargo_machete),
))
}
pub(crate) fn find_unused(
manifest_path: &Path,
with_cargo_metadata: UseCargoMetadata,
) -> anyhow::Result<Option<PackageAnalysis>> {
let mut dir_path = manifest_path.to_path_buf();
dir_path.pop();
trace!("trying to open {}...", manifest_path.display());
let (manifest, workspace_metadata) = get_full_manifest(&dir_path, manifest_path)?;
let package_name = match manifest.package {
Some(ref package) => package.name.clone(),
None => return Ok(None),
};
debug!("handling {} ({})", package_name, dir_path.display());
let mut analysis = PackageAnalysis::new(
package_name,
manifest_path,
manifest,
matches!(with_cargo_metadata, UseCargoMetadata::Yes),
)?;
let paths = collect_paths(&dir_path, &analysis);
let dependencies: BTreeMap<String, String> = if let Some((metadata, resolve)) = analysis
.metadata
.as_ref()
.and_then(|metadata| metadata.resolve.as_ref().map(|resolve| (metadata, resolve)))
{
if let Some(ref root) = resolve.root {
let root_node = resolve
.nodes
.iter()
.find(|node| node.id == *root)
.expect("root should be resolved by cargo-metadata");
let root_package = metadata
.packages
.iter()
.find(|pkg| pkg.id == *root)
.expect("root should appear under cargo-metadata packages");
root_node
.deps
.iter()
.map(|dep| {
let crate_name = dep.name.clone();
let dep_pkg = metadata
.packages
.iter()
.find(|pkg| pkg.id == dep.pkg)
.expect(
"resolved dependencies should appear under cargo-metadata packages",
);
let mut dep_spec_it = root_package
.dependencies
.iter()
.filter(|dep_spec| dep_spec.name == *dep_pkg.name);
let dep_spec = dep_spec_it
.next()
.expect("resolved dependency should have a matching dependency spec");
let dep_key = dep_spec
.rename
.clone()
.unwrap_or_else(|| dep_spec.name.clone());
(dep_key, crate_name)
})
.collect()
} else {
Default::default()
}
} else {
analysis
.manifest
.dependencies
.keys()
.map(|k| (k.clone(), k.replace('-', "_")))
.collect()
};
let meta = analysis
.manifest
.package
.as_ref()
.and_then(|package| package.metadata.as_ref()?.cargo_machete.as_ref());
let ignored = meta
.map(|meta| meta.ignored.iter().collect::<HashSet<_>>())
.unwrap_or_default();
static NO_RENAMED: BTreeMap<Box<str>, Box<str>> = BTreeMap::new();
let renamed = meta.map(|meta| &meta.renamed).unwrap_or(&NO_RENAMED);
let (workspace_ignored, workspace_renamed): (HashSet<_>, _) = workspace_metadata
.map(|MetadataFields { ignored, renamed }| (HashSet::from_iter(ignored), renamed))
.unwrap_or_default();
enum SingleDepResult {
Unused(String),
IgnoredButUsed(String),
}
let results: Vec<SingleDepResult> = dependencies
.into_par_iter()
.filter_map(|(dep_name, crate_name)| {
let crate_name = renamed
.get(dep_name.as_str())
.or_else(|| workspace_renamed.get(dep_name.as_str()))
.map_or(crate_name.as_str(), Box::as_ref);
let mut search = Search::new(crate_name).expect("constructing grep context");
let mut found_once = false;
for path in &paths {
trace!("looking for {} in {}", crate_name, path.to_string_lossy(),);
match search.search_path(path) {
Ok(true) => {
found_once = true;
break;
}
Ok(false) => {}
Err(err) => {
eprintln!("{}: {}", path.display(), err);
}
};
}
if !found_once {
if ignored.contains(&dep_name) || workspace_ignored.contains(&dep_name) {
return None;
}
Some(SingleDepResult::Unused(dep_name))
} else {
if ignored.contains(&dep_name) {
return Some(SingleDepResult::IgnoredButUsed(dep_name));
}
None
}
})
.collect();
for result in results {
match result {
SingleDepResult::Unused(dep) => analysis.unused.push(dep),
SingleDepResult::IgnoredButUsed(dep) => analysis.ignored_used.push(dep),
}
}
Ok(Some(analysis))
}
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::Searcher,
matsh: &searcher::SinkMatch<'_>,
) -> Result<bool, Self::Error> {
let mat = std::str::from_utf8(matsh.bytes())?;
let mat = mat.trim();
if mat.starts_with("//") || mat.starts_with("//!") {
return Ok(true);
}
self.found = true;
Ok(false)
}
}
#[test]
fn test_regexp() -> anyhow::Result<()> {
fn test_one(crate_name: &str, content: &str) -> anyhow::Result<bool> {
let mut search = Search::new(crate_name)?;
search.search_string(content)
}
assert!(!test_one("log", "use da_force_luke;")?);
assert!(!test_one("log", "use flog;")?);
assert!(!test_one("log", "use log_once;")?);
assert!(!test_one("log", "use log_once::info;")?);
assert!(!test_one("log", "use flog::flag;")?);
assert!(!test_one("log", "flog::flag;")?);
assert!(!test_one("log", "use ::flog;")?);
assert!(!test_one("log", "use :log;")?);
assert!(test_one("log", "use log;")?);
assert!(test_one("log", "use ::log;")?);
assert!(test_one("log", "use log::{self};")?);
assert!(test_one("log", "use log::*;")?);
assert!(test_one("log", "use log::info;")?);
assert!(test_one("log", "use log as logging;")?);
assert!(test_one("log", "extern crate log;")?);
assert!(test_one("log", "extern crate log as logging")?);
assert!(test_one("log", r#"log::info!("fyi")"#)?);
assert!(test_one("Log", "use log;")?);
assert!(test_one("Log", "use ::log;")?);
assert!(test_one("Log", "use log::{self};")?);
assert!(test_one("Log", "use log::*;")?);
assert!(test_one("Log", "use log::info;")?);
assert!(test_one("Log", "use log as logging;")?);
assert!(test_one("Log", "extern crate log;")?);
assert!(test_one("Log", "extern crate log as logging")?);
assert!(test_one("Log", r#"log::info!("fyi")"#)?);
assert!(test_one("log", "use Log;")?);
assert!(test_one("log", "use ::Log;")?);
assert!(test_one("log", "use Log::{self};")?);
assert!(test_one("log", "use Log::*;")?);
assert!(test_one("log", "use Log::info;")?);
assert!(test_one("log", "use Log as logging;")?);
assert!(test_one("log", "extern crate Log;")?);
assert!(test_one("log", "extern crate Log as logging")?);
assert!(test_one("log", r#"Log::info!("fyi")"#)?);
assert!(test_one(
"bitflags",
r#"
use std::fmt;
bitflags::macro! {
"#
)?);
assert!(test_one(
"Bitflags",
r#"
use std::fmt;
bitflags::macro! {
"#
)?);
assert!(test_one(
"bitflags",
r#"
use std::fmt;
Bitflags::macro! {
"#
)?);
assert!(test_one("log", "use { log as logging };")?);
assert!(!test_one("lol", "use { log as logging };")?);
assert!(test_one(
"log",
r#"
use {
log as logging
};
"#
)?);
assert!(test_one(
"log",
r#"
use { log as
logging
};
"#
)?);
assert!(test_one(
"log",
r#"
use { log
as
logging
};
"#
)?);
assert!(test_one(
"log",
r#"
use {
x::{ y },
log as logging,
};
"#
)?);
assert!(!test_one(
"log",
r#"
use {
x as y
};
type logging = u64;
fn main() {
let func = |log: u32| {
log as logging
};
func(42);
}
"#
)?);
assert!(test_one(
"static_assertions",
r#"
// lol
static_assertions::assert_not_impl_all!(A: B);
"#
)?);
assert!(test_one(
"futures",
r#"
// the [`futures::executor::block_on`] function
pub use futures::future;
"#
)?);
assert!(test_one(
"futures",
r#"pub use {async_trait, futures, reqwest};"#
)?);
assert!(test_one(
"futures",
r#"pub use {async_trait, ::futures, reqwest};"#
)?);
assert!(!test_one(
"futures",
r#"pub use {async_trait, not_futures::futures, reqwest};"#
)?);
assert!(!test_one(
"futures",
r#"
pub use {
async_trait,
not_futures::futures,
reqwest,
};"#
)?);
assert!(!test_one(
"futures",
r#"use not_futures::futures::stuff_in_futures;"#
)?);
assert!(test_one(
"futures",
r#"pub use {
async_trait::{mod1, dep2},
futures::{futures_mod1, futures_mod2::{futures_mod21, futures_mod22}},
reqwest,
};"#
)?);
assert!(test_one(
"futures",
r#"pub use {
async_trait::sub_mod::*,
futures as futures_renamed,
reqwest,
};"#
)?);
assert!(test_one(
"futures",
r#"pub use {
other_dep::{
star_mod::*,
unnamed_import::{UnnamedTrait as _, other_mod},
renamed_import as new_name,
sub_import::{mod1, mod2},
},
futures as futures_renamed,
reqwest,
};"#
)?);
assert!(!test_one(
"futures",
r#"pub use {
async_trait::{mod1, dep2},
not_futures::futures::{futures_mod1, futures_mod2::{futures_mod21, futures_mod22}},
reqwest,
};"#
)?);
assert!(test_one("futures", r#" ::futures::mod1"#)?);
Ok(())
}
#[cfg(test)]
fn check_analysis<F: Fn(PackageAnalysis)>(rel_path: &str, callback: F) {
for use_cargo_metadata in UseCargoMetadata::all() {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join(rel_path),
*use_cargo_metadata,
)
.expect("find_unused must return an Ok result")
.expect("no error during processing");
callback(analysis);
}
}
#[test]
fn test_just_unused() {
check_analysis("./integration-tests/just-unused/Cargo.toml", |analysis| {
assert_eq!(analysis.unused, &["log".to_string()]);
});
}
#[test]
fn test_just_unused_with_manifest() {
check_analysis(
"./integration-tests/workspace-package/program/Cargo.toml",
|analysis| {
assert_eq!(analysis.unused, &["log".to_string()]);
},
);
}
#[test]
fn test_unused_transitive() {
check_analysis(
"./integration-tests/unused-transitive/lib1/Cargo.toml",
|analysis| {
assert!(analysis.unused.is_empty());
},
);
check_analysis(
"./integration-tests/unused-transitive/lib2/Cargo.toml",
|analysis| {
assert!(analysis.unused.is_empty());
},
);
check_analysis(
"./integration-tests/unused-transitive/Cargo.toml",
|analysis| {
assert_eq!(analysis.unused, &["lib1".to_string()]);
},
);
}
#[test]
fn test_false_positive_macro_use() {
check_analysis(
"./integration-tests/false-positive-log/Cargo.toml",
|analysis| {
assert_eq!(analysis.unused, &["log".to_string()]);
},
);
}
#[test]
fn test_with_bench() {
check_analysis(
"./integration-tests/with-bench/bench/Cargo.toml",
|analysis| {
assert!(analysis.unused.is_empty());
},
);
}
#[test]
fn test_renamed_field_works() -> anyhow::Result<()> {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/renamed-dep/Cargo.toml"),
UseCargoMetadata::No,
)?
.expect("no error during processing");
assert_eq!(analysis.unused.as_slice(), &["bytes", "log"]);
Ok(())
}
#[test]
fn test_renamed_field_workspace_works() -> anyhow::Result<()> {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL)
.join("./integration-tests/renamed-dep-workspace/inner/Cargo.toml"),
UseCargoMetadata::No,
)?
.expect("no error during processing");
assert_eq!(analysis.unused.as_slice(), &["bytes", "flagset"]);
Ok(())
}
#[test]
fn test_crate_renaming_works() -> anyhow::Result<()> {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/renaming-works/Cargo.toml"),
UseCargoMetadata::Yes,
)?
.expect("no error during processing");
assert!(analysis.unused.is_empty());
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/renaming-works/Cargo.toml"),
UseCargoMetadata::No,
)?
.expect("no error during processing");
assert_eq!(analysis.unused, &["xml-rs".to_string()]);
Ok(())
}
#[test]
fn test_unused_renamed_in_registry() -> anyhow::Result<()> {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/unused-renamed-in-registry/Cargo.toml"),
UseCargoMetadata::Yes,
)?
.expect("no error during processing");
assert_eq!(analysis.unused, &["xml-rs".to_string()]);
Ok(())
}
#[test]
fn test_unused_renamed_in_spec() -> anyhow::Result<()> {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/unused-renamed-in-spec/Cargo.toml"),
UseCargoMetadata::Yes,
)?
.expect("no error during processing");
assert_eq!(analysis.unused, &["tracing".to_string()]);
Ok(())
}
#[test]
fn test_unused_kebab_spec() -> anyhow::Result<()> {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/unused-kebab-spec/Cargo.toml"),
UseCargoMetadata::Yes,
)?
.expect("no error during processing");
assert_eq!(analysis.unused, &["log-once".to_string()]);
Ok(())
}
#[test]
fn test_ignore_deps_works() {
check_analysis("./integration-tests/ignored-dep/Cargo.toml", |analysis| {
assert_eq!(analysis.unused, &["rand".to_string()]);
assert_eq!(analysis.ignored_used, &["rand_core".to_string()]);
});
}
#[test]
fn test_ignore_deps_workspace_works() {
check_analysis(
"./integration-tests/ignored-dep-workspace/inner/Cargo.toml",
|analysis| {
assert_eq!(analysis.unused, &["rand".to_string()]);
assert_eq!(analysis.ignored_used, &["rand_core".to_string()]);
},
);
}
#[test]
fn test_workspace_from_relative_path() {
use std::env::{current_dir, set_current_dir};
let prev_cwd = current_dir().unwrap();
set_current_dir(
PathBuf::from(TOP_LEVEL).join("./integration-tests/workspace-package/program/"),
)
.unwrap();
let path = Path::new("./Cargo.toml");
let analysis = find_unused(path, UseCargoMetadata::No);
set_current_dir(prev_cwd).unwrap();
let analysis = analysis
.expect("find_unused must return an Ok result")
.expect("no error during processing");
assert_eq!(analysis.unused, &["log".to_string()]);
assert!(analysis.ignored_used.is_empty());
}
#[test]
fn test_multi_key_dep() {
let analysis = find_unused(
&PathBuf::from(TOP_LEVEL).join("./integration-tests/multi-key-dep/Cargo.toml"),
UseCargoMetadata::Yes,
)
.expect("find_unused must return an Ok result")
.expect("no error during processing");
assert_eq!(analysis.unused, &["cc".to_string(), "rand".to_string()]);
}