use std::path::{Path, PathBuf};
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_config::{PackageJson, ResolvedConfig};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::results::{
DependencyLocation, ImportSite, TestOnlyDependency, TypeOnlyDependency, UnlistedDependency,
UnresolvedImport, UnusedDependency,
};
use crate::suppress::{IssueKind, SuppressionContext};
use super::package_json_utils::{find_dep_line_in_json, read_pkg_json_content};
use super::predicates::{
is_builtin_module, is_config_file, is_implicit_dependency, is_path_alias, is_virtual_module,
};
use super::{LineOffsetsMap, byte_offset_to_line_col};
fn is_package_json_ignored(ws_pkg_path: &Path, config: &ResolvedConfig) -> bool {
let relative = ws_pkg_path
.strip_prefix(&config.root)
.unwrap_or(ws_pkg_path);
config.ignore_patterns.is_match(relative)
}
pub struct DepCategoryConfig {
pub location: DependencyLocation,
pub check_implicit: bool,
pub check_known_tooling: bool,
pub check_plugin_tooling: bool,
}
pub struct SharedDepSets<'a> {
pub plugin_referenced: &'a FxHashSet<&'a str>,
pub plugin_tooling: &'a FxHashSet<&'a str>,
pub script_used: &'a FxHashSet<&'a str>,
pub workspace_names: &'a FxHashSet<&'a str>,
pub ignore_deps: &'a FxHashSet<&'a str>,
}
pub fn collect_unused_for_category(
dep_names: Vec<String>,
category: &DepCategoryConfig,
shared: &SharedDepSets<'_>,
is_used: impl Fn(&str) -> bool,
pkg_path: &Path,
pkg_content: Option<&str>,
) -> Vec<UnusedDependency> {
dep_names
.into_iter()
.filter(|dep| !is_used(dep))
.filter(|dep| !shared.script_used.contains(dep.as_str()))
.filter(|dep| !category.check_implicit || !is_implicit_dependency(dep))
.filter(|dep| {
!category.check_known_tooling || !crate::plugins::is_known_tooling_dependency(dep)
})
.filter(|dep| {
!category.check_plugin_tooling || !shared.plugin_tooling.contains(dep.as_str())
})
.filter(|dep| !shared.plugin_referenced.contains(dep.as_str()))
.filter(|dep| !shared.ignore_deps.contains(dep.as_str()))
.filter(|dep| !shared.workspace_names.contains(dep.as_str()))
.map(|dep| {
let line = pkg_content.map_or(1, |c| find_dep_line_in_json(c, &dep));
UnusedDependency {
package_name: dep,
location: category.location.clone(),
path: pkg_path.to_path_buf(),
line,
}
})
.collect()
}
const fn prod_category() -> DepCategoryConfig {
DepCategoryConfig {
location: DependencyLocation::Dependencies,
check_implicit: true,
check_known_tooling: false,
check_plugin_tooling: true,
}
}
const fn dev_category() -> DepCategoryConfig {
DepCategoryConfig {
location: DependencyLocation::DevDependencies,
check_implicit: false,
check_known_tooling: true,
check_plugin_tooling: true,
}
}
const fn optional_category() -> DepCategoryConfig {
DepCategoryConfig {
location: DependencyLocation::OptionalDependencies,
check_implicit: true,
check_known_tooling: false,
check_plugin_tooling: false,
}
}
pub fn find_unused_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
workspaces: &[fallow_config::WorkspaceInfo],
) -> (
Vec<UnusedDependency>,
Vec<UnusedDependency>,
Vec<UnusedDependency>,
) {
let plugin_referenced: FxHashSet<&str> = plugin_result
.map(|pr| {
pr.referenced_dependencies
.iter()
.map(String::as_str)
.collect()
})
.unwrap_or_default();
let plugin_tooling: FxHashSet<&str> = plugin_result
.map(|pr| pr.tooling_dependencies.iter().map(String::as_str).collect())
.unwrap_or_default();
let script_used: FxHashSet<&str> = plugin_result
.map(|pr| pr.script_used_packages.iter().map(String::as_str).collect())
.unwrap_or_default();
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
let ignore_deps: FxHashSet<&str> = config
.ignore_dependencies
.iter()
.map(String::as_str)
.collect();
let used_packages: FxHashSet<&str> = graph.package_usage.keys().map(String::as_str).collect();
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let shared = SharedDepSets {
plugin_referenced: &plugin_referenced,
plugin_tooling: &plugin_tooling,
script_used: &script_used,
workspace_names: &workspace_names,
ignore_deps: &ignore_deps,
};
let is_used_globally = |dep: &str| used_packages.contains(dep);
let mut unused_deps = collect_unused_for_category(
pkg.production_dependency_names(),
&prod_category(),
&shared,
is_used_globally,
&root_pkg_path,
root_pkg_content.as_deref(),
);
let mut unused_dev_deps = collect_unused_for_category(
pkg.dev_dependency_names(),
&dev_category(),
&shared,
is_used_globally,
&root_pkg_path,
root_pkg_content.as_deref(),
);
let mut unused_optional_deps = collect_unused_for_category(
pkg.optional_dependency_names(),
&optional_category(),
&shared,
is_used_globally,
&root_pkg_path,
root_pkg_content.as_deref(),
);
let root_flagged: FxHashSet<String> = unused_deps
.iter()
.chain(unused_dev_deps.iter())
.chain(unused_optional_deps.iter())
.map(|d| d.package_name.clone())
.collect();
for ws in workspaces {
let ws_pkg_path = ws.root.join("package.json");
if is_package_json_ignored(&ws_pkg_path, config) {
continue;
}
let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) else {
continue;
};
let ws_pkg_content = read_pkg_json_content(&ws_pkg_path);
let ws_root = &ws.root;
let is_used_in_workspace = |dep: &str| -> bool {
root_flagged.contains(dep)
|| graph.package_usage.get(dep).is_some_and(|file_ids| {
file_ids.iter().any(|id| {
graph
.modules
.get(id.0 as usize)
.is_some_and(|module| module.path.starts_with(ws_root))
})
})
};
unused_deps.extend(collect_unused_for_category(
ws_pkg.production_dependency_names(),
&prod_category(),
&shared,
is_used_in_workspace,
&ws_pkg_path,
ws_pkg_content.as_deref(),
));
unused_dev_deps.extend(collect_unused_for_category(
ws_pkg.dev_dependency_names(),
&dev_category(),
&shared,
is_used_in_workspace,
&ws_pkg_path,
ws_pkg_content.as_deref(),
));
unused_optional_deps.extend(collect_unused_for_category(
ws_pkg.optional_dependency_names(),
&optional_category(),
&shared,
is_used_in_workspace,
&ws_pkg_path,
ws_pkg_content.as_deref(),
));
}
(unused_deps, unused_dev_deps, unused_optional_deps)
}
#[cfg(test)]
fn should_skip_dependency(
dep: &str,
root_flagged: &FxHashSet<String>,
script_used: &FxHashSet<&str>,
plugin_referenced: &FxHashSet<&str>,
ignore_deps: &FxHashSet<&str>,
workspace_names: &FxHashSet<&str>,
is_used_in_workspace: impl Fn(&str) -> bool,
) -> bool {
root_flagged.contains(dep)
|| script_used.contains(dep)
|| plugin_referenced.contains(dep)
|| ignore_deps.contains(dep)
|| workspace_names.contains(dep)
|| is_used_in_workspace(dep)
}
pub fn find_type_only_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<TypeOnlyDependency> {
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
let mut type_only_deps = Vec::new();
for dep in pkg.production_dependency_names() {
if workspace_names.contains(dep.as_str()) {
continue;
}
if config.ignore_dependencies.iter().any(|d| d == &dep) {
continue;
}
let has_any_usage = graph.package_usage.contains_key(dep.as_str());
let has_type_only_usage = graph.type_only_package_usage.contains_key(dep.as_str());
if !has_any_usage {
continue;
}
let total_count = graph.package_usage.get(dep.as_str()).map_or(0, Vec::len);
let type_only_count = graph
.type_only_package_usage
.get(dep.as_str())
.map_or(0, Vec::len);
if has_type_only_usage && type_only_count == total_count {
let line = root_pkg_content
.as_deref()
.map_or(1, |c| find_dep_line_in_json(c, &dep));
type_only_deps.push(TypeOnlyDependency {
package_name: dep,
path: root_pkg_path.clone(),
line,
});
}
}
type_only_deps
}
pub fn find_test_only_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<TestOnlyDependency> {
let test_globs = {
let mut builder = globset::GlobSetBuilder::new();
for pattern in crate::discover::PRODUCTION_EXCLUDE_PATTERNS {
if let Ok(glob) = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
{
builder.add(glob);
}
}
match builder.build() {
Ok(set) => set,
Err(_) => return Vec::new(),
}
};
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
let ignore_deps: FxHashSet<&str> = config
.ignore_dependencies
.iter()
.map(String::as_str)
.collect();
let mut test_only_deps = Vec::new();
for dep in pkg.production_dependency_names() {
if workspace_names.contains(dep.as_str()) {
continue;
}
if ignore_deps.contains(dep.as_str()) {
continue;
}
let Some(file_ids) = graph.package_usage.get(dep.as_str()) else {
continue;
};
let total_count = file_ids.len();
let type_only_count = graph
.type_only_package_usage
.get(dep.as_str())
.map_or(0, Vec::len);
if type_only_count == total_count {
continue;
}
let all_test_only = file_ids.iter().all(|id| {
graph.modules.get(id.0 as usize).is_some_and(|module| {
let relative = module
.path
.strip_prefix(&config.root)
.unwrap_or(&module.path);
test_globs.is_match(relative) || is_config_file(&module.path)
})
});
if all_test_only {
let line = root_pkg_content
.as_deref()
.map_or(1, |c| find_dep_line_in_json(c, &dep));
test_only_deps.push(TestOnlyDependency {
package_name: dep,
path: root_pkg_path.clone(),
line,
});
}
}
test_only_deps
}
pub fn is_package_listed_for_file(
file_path: &Path,
package_name: &str,
root_deps: &FxHashSet<String>,
ws_dep_map: &[(PathBuf, FxHashSet<String>)],
) -> bool {
if root_deps.contains(package_name) {
return true;
}
ws_dep_map
.iter()
.any(|(ws_root, ws_deps)| file_path.starts_with(ws_root) && ws_deps.contains(package_name))
}
fn has_types_package(package_name: &str, all_workspace_deps: &FxHashSet<String>) -> bool {
let types_name = package_name.strip_prefix('@').map_or_else(
|| format!("@types/{package_name}"),
|scoped| format!("@types/{}", scoped.replacen('/', "__", 1)),
);
all_workspace_deps.contains(&types_name)
}
pub fn find_import_location(
import_spans_by_file: &FxHashMap<FileId, Vec<(&str, u32)>>,
line_offsets_by_file: &LineOffsetsMap<'_>,
file_id: FileId,
package_name: &str,
) -> (u32, u32) {
import_spans_by_file
.get(&file_id)
.and_then(|spans| {
spans
.iter()
.find(|(name, _)| *name == package_name)
.map(|(_, span_start)| {
byte_offset_to_line_col(line_offsets_by_file, file_id, *span_start)
})
})
.unwrap_or((1, 0))
}
pub fn find_unlisted_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
resolved_modules: &[ResolvedModule],
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnlistedDependency> {
let all_deps: FxHashSet<String> = pkg.all_dependency_names().into_iter().collect();
let mut all_workspace_deps: FxHashSet<String> = all_deps.clone();
let mut workspace_names: FxHashSet<String> = FxHashSet::default();
let mut ws_dep_map: Vec<(PathBuf, FxHashSet<String>)> = Vec::new();
for ws in workspaces {
workspace_names.insert(ws.name.clone());
let ws_pkg_path = ws.root.join("package.json");
if is_package_json_ignored(&ws_pkg_path, config) {
continue;
}
if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
let ws_deps: FxHashSet<String> = ws_pkg.all_dependency_names().into_iter().collect();
all_workspace_deps.extend(ws_deps.iter().cloned());
ws_dep_map.push((ws.root.clone(), ws_deps));
}
}
let virtual_prefixes: Vec<&str> = plugin_result
.map(|pr| {
pr.virtual_module_prefixes
.iter()
.map(String::as_str)
.collect()
})
.unwrap_or_default();
let plugin_tooling: FxHashSet<&str> = plugin_result
.map(|pr| pr.tooling_dependencies.iter().map(String::as_str).collect())
.unwrap_or_default();
let mut import_spans_by_file: FxHashMap<FileId, Vec<(&str, u32)>> = FxHashMap::default();
for rm in resolved_modules {
for import in &rm.resolved_imports {
if let crate::resolve::ResolveResult::NpmPackage(name) = &import.target {
import_spans_by_file
.entry(rm.file_id)
.or_default()
.push((name.as_str(), import.info.span.start));
}
}
}
let ignore_deps: FxHashSet<&str> = config
.ignore_dependencies
.iter()
.map(String::as_str)
.collect();
let mut unlisted: FxHashMap<String, Vec<ImportSite>> = FxHashMap::default();
for (package_name, file_ids) in &graph.package_usage {
if is_builtin_module(package_name) || is_path_alias(package_name) {
continue;
}
if is_virtual_module(package_name) {
continue;
}
if workspace_names.contains(package_name) {
continue;
}
if ignore_deps.contains(package_name.as_str()) {
continue;
}
if plugin_tooling.contains(package_name.as_str()) {
continue;
}
if virtual_prefixes.iter().any(|prefix| {
package_name.starts_with(prefix)
|| prefix
.strip_suffix('/')
.is_some_and(|base| package_name == base)
}) {
continue;
}
if all_workspace_deps.contains(package_name) {
continue;
}
if has_types_package(package_name, &all_workspace_deps) {
continue;
}
let mut unlisted_sites: Vec<ImportSite> = Vec::new();
for id in file_ids {
let Some(module) = graph.modules.get(id.0 as usize) else {
continue;
};
if is_package_listed_for_file(&module.path, package_name, &all_deps, &ws_dep_map) {
continue;
}
let (line, col) = find_import_location(
&import_spans_by_file,
line_offsets_by_file,
*id,
package_name,
);
unlisted_sites.push(ImportSite {
path: module.path.clone(),
line,
col,
});
}
if !unlisted_sites.is_empty() {
unlisted_sites.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
unlisted_sites.dedup_by(|a, b| a.path == b.path);
unlisted.insert(package_name.clone(), unlisted_sites);
}
}
unlisted
.into_iter()
.map(|(name, sites)| UnlistedDependency {
package_name: name,
imported_from: sites,
})
.collect()
}
pub fn find_unresolved_imports(
resolved_modules: &[ResolvedModule],
_config: &ResolvedConfig,
suppressions: &SuppressionContext<'_>,
virtual_prefixes: &[&str],
generated_patterns: &[&str],
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnresolvedImport> {
let mut unresolved = Vec::new();
for module in resolved_modules {
for import in &module.resolved_imports {
if let crate::resolve::ResolveResult::Unresolvable(spec) = &import.target {
if is_virtual_module(spec) {
continue;
}
if virtual_prefixes
.iter()
.any(|prefix| spec.starts_with(prefix))
{
continue;
}
if !generated_patterns.is_empty() {
let bare = spec
.strip_suffix(".js")
.or_else(|| spec.strip_suffix(".ts"))
.unwrap_or(spec);
if generated_patterns.iter().any(|pat| bare.ends_with(pat)) {
continue;
}
}
let (line, col) = byte_offset_to_line_col(
line_offsets_by_file,
module.file_id,
import.info.span.start,
);
let specifier_col = if import.info.source_span.end > import.info.source_span.start {
let (_, sc) = byte_offset_to_line_col(
line_offsets_by_file,
module.file_id,
import.info.source_span.start,
);
sc
} else {
col
};
if suppressions.is_suppressed(module.file_id, line, IssueKind::UnresolvedImport) {
continue;
}
unresolved.push(UnresolvedImport {
path: module.path.clone(),
specifier: spec.clone(),
line,
col,
specifier_col,
});
}
}
}
unresolved
}
#[cfg(test)]
#[path = "unused_deps_tests/mod.rs"]
mod tests;