use rustc_hash::{FxHashMap, FxHashSet};
use fallow_config::ResolvedConfig;
use crate::discover::FileId;
use crate::graph::{ModuleGraph, ModuleNode};
use crate::results::{
DuplicateExport, DuplicateLocation, ExportUsage, ReferenceLocation, UnusedExport,
};
use crate::suppress::{self, IssueKind, Suppression};
use super::{LineOffsetsMap, byte_offset_to_line_col, read_source};
type IgnoreMatchers<'a> = Vec<(globset::GlobMatcher, &'a [String])>;
type PluginMatchers<'a> = Vec<(globset::GlobMatcher, Vec<&'a str>)>;
fn compile_ignore_matchers(config: &ResolvedConfig) -> IgnoreMatchers<'_> {
config
.ignore_export_rules
.iter()
.filter_map(|rule| {
globset::Glob::new(&rule.file)
.ok()
.map(|g| (g.compile_matcher(), rule.exports.as_slice()))
})
.collect()
}
fn compile_plugin_matchers(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> PluginMatchers<'_> {
let Some(pr) = plugin_result else {
return Vec::new();
};
pr.used_exports
.iter()
.filter_map(|(file_pat, exports)| {
globset::Glob::new(file_pat).ok().map(|g| {
(
g.compile_matcher(),
exports.iter().map(String::as_str).collect::<Vec<_>>(),
)
})
})
.collect()
}
fn should_skip_module(module: &ModuleNode) -> bool {
if module.is_entry_point {
return true;
}
if !module.is_reachable {
return module.exports.iter().all(|e| e.references.is_empty());
}
if module.has_cjs_exports && module.exports.is_empty() {
return true;
}
module.path.extension().is_some_and(|ext| ext == "svelte")
}
fn is_export_ignored(
export_name: &str,
matching_ignore: &[&[String]],
matching_plugin: &[&Vec<&str>],
) -> bool {
matching_ignore
.iter()
.any(|exports| exports.iter().any(|e| e == "*" || e == export_name))
|| matching_plugin
.iter()
.any(|exports| exports.contains(&export_name))
}
pub fn find_unused_exports(
graph: &ModuleGraph,
config: &ResolvedConfig,
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
suppressions_by_file: &FxHashMap<FileId, &[Suppression]>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (Vec<UnusedExport>, Vec<UnusedExport>) {
let mut unused_exports = Vec::new();
let mut unused_types = Vec::new();
let ignore_matchers = compile_ignore_matchers(config);
let plugin_matchers = compile_plugin_matchers(plugin_result);
let reachable_files: FxHashSet<u32> = graph
.modules
.iter()
.filter(|m| m.is_reachable)
.map(|m| m.file_id.0)
.collect();
for module in &graph.modules {
if should_skip_module(module) {
continue;
}
let relative_path = module
.path
.strip_prefix(&config.root)
.unwrap_or(&module.path);
let file_str = relative_path.to_string_lossy();
let matching_ignore: Vec<&[String]> = ignore_matchers
.iter()
.filter(|(m, _)| m.is_match(file_str.as_ref()))
.map(|(_, exports)| *exports)
.collect();
let matching_plugin: Vec<&Vec<&str>> = plugin_matchers
.iter()
.filter(|(m, _)| m.is_match(file_str.as_ref()))
.map(|(_, exports)| exports)
.collect();
for export in &module.exports {
let is_referenced = if module.is_reachable {
!export.references.is_empty()
} else {
export
.references
.iter()
.any(|r| reachable_files.contains(&r.from_file.0))
};
if export.is_public || is_referenced {
continue;
}
let export_str = export.name.to_string();
if is_export_ignored(&export_str, &matching_ignore, &matching_plugin) {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
let is_re_export = export.span.start == 0 && export.span.end == 0;
let issue_kind = if export.is_type_only {
IssueKind::UnusedType
} else {
IssueKind::UnusedExport
};
if let Some(supps) = suppressions_by_file.get(&module.file_id)
&& suppress::is_suppressed(supps, line, issue_kind)
{
continue;
}
let unused = UnusedExport {
path: module.path.clone(),
export_name: export_str,
is_type_only: export.is_type_only,
line,
col,
span_start: export.span.start,
is_re_export,
};
if export.is_type_only {
unused_types.push(unused);
} else {
unused_exports.push(unused);
}
}
}
(unused_exports, unused_types)
}
pub fn find_duplicate_exports(
graph: &ModuleGraph,
suppressions_by_file: &FxHashMap<FileId, &[Suppression]>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<DuplicateExport> {
let mut re_export_sources: FxHashMap<usize, FxHashSet<usize>> = FxHashMap::default();
for (idx, module) in graph.modules.iter().enumerate() {
for re in &module.re_exports {
re_export_sources
.entry(idx)
.or_default()
.insert(re.source_file.0 as usize);
}
}
let mut export_locations: FxHashMap<String, Vec<(usize, std::path::PathBuf, FileId, u32)>> =
FxHashMap::default();
for (idx, module) in graph.modules.iter().enumerate() {
if !module.is_reachable || module.is_entry_point {
continue;
}
if suppressions_by_file
.get(&module.file_id)
.is_some_and(|supps| suppress::is_file_suppressed(supps, IssueKind::DuplicateExport))
{
continue;
}
for export in &module.exports {
if matches!(export.name, crate::extract::ExportName::Default) {
continue; }
if export.span.start == 0 && export.span.end == 0 {
continue;
}
let name = export.name.to_string();
export_locations.entry(name).or_default().push((
idx,
module.path.clone(),
module.file_id,
export.span.start,
));
}
}
let mut sorted_locations: Vec<_> = export_locations.into_iter().collect();
sorted_locations.sort_by(|a, b| a.0.cmp(&b.0));
sorted_locations
.into_iter()
.filter_map(|(name, locations)| {
if locations.len() <= 1 {
return None;
}
let module_indices: FxHashSet<usize> =
locations.iter().map(|(idx, _, _, _)| *idx).collect();
let independent: Vec<DuplicateLocation> = locations
.into_iter()
.filter(|(idx, _, _, _)| {
let sources = re_export_sources.get(idx);
let has_source_in_set =
sources.is_some_and(|s| s.iter().any(|src| module_indices.contains(src)));
!has_source_in_set
})
.map(|(_, path, file_id, span_start)| {
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, file_id, span_start);
DuplicateLocation { path, line, col }
})
.collect();
if independent.len() > 1 {
Some(DuplicateExport {
export_name: name,
locations: independent,
})
} else {
None
}
})
.collect()
}
pub fn collect_export_usages(
graph: &ModuleGraph,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<ExportUsage> {
let mut usages = Vec::new();
let file_paths: FxHashMap<FileId, &std::path::Path> = graph
.modules
.iter()
.map(|m| (m.file_id, m.path.as_path()))
.collect();
let mut source_cache: FxHashMap<FileId, (String, Vec<u32>)> = FxHashMap::default();
for module in &graph.modules {
if !module.is_reachable {
continue;
}
for export in &module.exports {
if export.span.start == 0 && export.span.end == 0 {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
let reference_locations: Vec<ReferenceLocation> = export
.references
.iter()
.filter_map(|r| {
if r.import_span.start == 0 && r.import_span.end == 0 {
return None;
}
let ref_path = file_paths.get(&r.from_file)?;
let (ref_line, ref_col) = if line_offsets_by_file.contains_key(&r.from_file) {
byte_offset_to_line_col(
line_offsets_by_file,
r.from_file,
r.import_span.start,
)
} else {
let (_, offsets) = source_cache.entry(r.from_file).or_insert_with(|| {
let src = read_source(ref_path);
let ofs = fallow_types::extract::compute_line_offsets(&src);
(src, ofs)
});
fallow_types::extract::byte_offset_to_line_col(offsets, r.import_span.start)
};
Some(ReferenceLocation {
path: ref_path.to_path_buf(),
line: ref_line,
col: ref_col,
})
})
.collect();
usages.push(ExportUsage {
path: module.path.clone(),
export_name: export.name.to_string(),
line,
col,
reference_count: export.references.len(),
reference_locations,
});
}
}
usages
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
use crate::extract::ExportName;
use crate::graph::{ExportSymbol, ModuleGraph, ReExportEdge, SymbolReference};
use crate::resolve::ResolvedModule;
use oxc_span::Span;
use std::path::PathBuf;
#[expect(
clippy::cast_possible_truncation,
reason = "test file counts are trivially small"
)]
fn build_graph(file_specs: &[(&str, bool)]) -> ModuleGraph {
let files: Vec<DiscoveredFile> = file_specs
.iter()
.enumerate()
.map(|(i, (path, _))| DiscoveredFile {
id: FileId(i as u32),
path: PathBuf::from(path),
size_bytes: 0,
})
.collect();
let entry_points: Vec<EntryPoint> = file_specs
.iter()
.filter(|(_, is_entry)| *is_entry)
.map(|(path, _)| EntryPoint {
path: PathBuf::from(path),
source: EntryPointSource::ManualEntry,
})
.collect();
let resolved_modules: Vec<ResolvedModule> = files
.iter()
.map(|f| ResolvedModule {
file_id: f.id,
path: f.path.clone(),
exports: vec![],
re_exports: vec![],
resolved_imports: vec![],
resolved_dynamic_imports: vec![],
resolved_dynamic_patterns: vec![],
member_accesses: vec![],
whole_object_uses: vec![],
has_cjs_exports: false,
unused_import_bindings: FxHashSet::default(),
})
.collect();
ModuleGraph::build(&resolved_modules, &entry_points, &files)
}
fn test_config() -> ResolvedConfig {
fallow_config::FallowConfig {
schema: None,
extends: vec![],
entry: vec![],
ignore_patterns: vec![],
framework: vec![],
workspaces: None,
ignore_dependencies: vec![],
ignore_exports: vec![],
duplicates: fallow_config::DuplicatesConfig::default(),
health: fallow_config::HealthConfig::default(),
rules: fallow_config::RulesConfig::default(),
boundaries: fallow_config::BoundaryConfig::default(),
production: false,
plugins: vec![],
dynamically_loaded: vec![],
overrides: vec![],
regression: None,
codeowners: None,
public_packages: vec![],
}
.resolve(
PathBuf::from("/tmp/test"),
fallow_config::OutputFormat::Human,
1,
true,
true,
)
}
fn make_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: false,
is_public: false,
span: Span::new(span_start, span_end),
references: vec![],
members: vec![],
}
}
fn make_referenced_export(
name: &str,
span_start: u32,
span_end: u32,
from: u32,
) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: false,
is_public: false,
span: Span::new(span_start, span_end),
references: vec![SymbolReference {
from_file: FileId(from),
kind: crate::graph::ReferenceKind::NamedImport,
import_span: Span::new(0, 10),
}],
members: vec![],
}
}
#[test]
fn duplicate_exports_empty_graph() {
let graph = build_graph(&[]);
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_no_duplicates_single_module() {
let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/utils.ts", false)]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20), make_export("bar", 30, 40)];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_detects_same_name_in_two_modules() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "helper");
assert_eq!(result[0].locations.len(), 2);
}
#[test]
fn duplicate_exports_skips_default_exports() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Default,
is_type_only: false,
is_public: false,
span: Span::new(10, 20),
references: vec![],
members: vec![],
}];
graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![ExportSymbol {
name: ExportName::Default,
is_type_only: false,
is_public: false,
span: Span::new(10, 20),
references: vec![],
members: vec![],
}];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_skips_synthetic_re_export_entries() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 0, 0)]; graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![make_export("helper", 10, 20)]; let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_skips_unreachable_modules() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_skips_entry_points() {
let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/b.ts", false)]);
graph.modules[0].exports = vec![make_export("helper", 10, 20)];
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_filters_re_export_chains() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/index.ts", false),
("/src/helper.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[1].re_exports = vec![ReExportEdge {
source_file: FileId(2),
imported_name: "helper".to_string(),
exported_name: "helper".to_string(),
is_type_only: false,
}];
graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![make_export("helper", 5, 15)];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_suppressed_file_wide() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
let supp = vec![Suppression {
line: 0,
kind: Some(IssueKind::DuplicateExport),
}];
let mut suppressions: FxHashMap<FileId, &[Suppression]> = FxHashMap::default();
suppressions.insert(FileId(2), &supp);
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_three_modules_same_name() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
("/src/c.ts", false),
]);
for i in 1..=3 {
graph.modules[i].is_reachable = true;
graph.modules[i].exports = vec![make_export("sharedFn", 10, 20)];
}
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "sharedFn");
assert_eq!(result[0].locations.len(), 3);
}
#[test]
fn duplicate_exports_different_names_not_duplicated() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![make_export("bar", 10, 20)];
let suppressions = FxHashMap::default();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
fn test_config_with_ignore_exports(
rules: Vec<fallow_config::IgnoreExportRule>,
) -> ResolvedConfig {
fallow_config::FallowConfig {
schema: None,
extends: vec![],
entry: vec![],
ignore_patterns: vec![],
framework: vec![],
workspaces: None,
ignore_dependencies: vec![],
ignore_exports: rules,
duplicates: fallow_config::DuplicatesConfig::default(),
health: fallow_config::HealthConfig::default(),
rules: fallow_config::RulesConfig::default(),
boundaries: fallow_config::BoundaryConfig::default(),
production: false,
plugins: vec![],
dynamically_loaded: vec![],
overrides: vec![],
regression: None,
codeowners: None,
public_packages: vec![],
}
.resolve(
PathBuf::from("/tmp/test"),
fallow_config::OutputFormat::Human,
1,
true,
true,
)
}
fn make_plugin_result(
used_exports: Vec<(String, Vec<String>)>,
) -> crate::plugins::AggregatedPluginResult {
crate::plugins::AggregatedPluginResult {
entry_patterns: vec![],
config_patterns: vec![],
always_used: vec![],
used_exports,
referenced_dependencies: vec![],
discovered_always_used: vec![],
setup_files: vec![],
tooling_dependencies: vec![],
script_used_packages: FxHashSet::default(),
virtual_module_prefixes: vec![],
generated_import_patterns: vec![],
path_aliases: vec![],
active_plugins: vec![],
fixture_patterns: vec![],
}
}
fn make_type_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: true,
is_public: false,
span: Span::new(span_start, span_end),
references: vec![],
members: vec![],
}
}
#[test]
fn unused_exports_empty_graph() {
let graph = build_graph(&[]);
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_detects_unreferenced_export() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "helper");
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_referenced_export() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_referenced_export("helper", 10, 20, 0)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_public_export() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("publicFn".to_string()),
is_type_only: false,
is_public: true,
span: Span::new(10, 20),
references: vec![],
members: vec![],
}];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_separates_types_from_values() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![
make_export("valueFn", 10, 20),
make_type_export("MyType", 30, 40),
];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "valueFn");
assert_eq!(types.len(), 1);
assert_eq!(types[0].export_name, "MyType");
}
#[test]
fn unused_exports_skips_unreachable_module() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/dead.ts", false),
]);
graph.modules[1].exports = vec![make_export("orphan", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_entry_point() {
let mut graph = build_graph(&[("/tmp/test/src/entry.ts", true)]);
graph.modules[0].exports = vec![make_export("main", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_cjs_only_module() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/legacy.js", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].has_cjs_exports = true;
graph.modules[1].exports = vec![];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_does_not_skip_cjs_module_with_named_exports() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/mixed.js", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].has_cjs_exports = true;
graph.modules[1].exports = vec![make_export("namedFn", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "namedFn");
}
#[test]
fn unused_exports_skips_svelte_files() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/Component.svelte", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("count", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_reports_reachable_non_entry_non_cjs_non_svelte() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].has_cjs_exports = false;
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "helper");
}
#[test]
fn unused_exports_empty_ignore_config() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config(); let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"no ignore rules, export should be reported"
);
}
#[test]
fn unused_exports_ignore_multiple_patterns() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/types.ts", false),
("/tmp/test/src/constants.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("MyType", 10, 20)];
graph.modules[2].is_reachable = true;
graph.modules[2].exports = vec![make_export("MY_CONST", 10, 20)];
let config = test_config_with_ignore_exports(vec![
fallow_config::IgnoreExportRule {
file: "src/types.ts".to_string(),
exports: vec!["*".to_string()],
},
fallow_config::IgnoreExportRule {
file: "src/constants.ts".to_string(),
exports: vec!["MY_CONST".to_string()],
},
]);
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"both exports should be ignored by config rules"
);
}
#[test]
fn unused_exports_invalid_ignore_glob_skipped() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "[invalid".to_string(),
exports: vec!["*".to_string()],
}]);
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"invalid glob should be skipped, export still reported"
);
}
#[test]
fn unused_exports_ignore_wildcard_matches_all() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/types.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("TypeA", 10, 20), make_export("TypeB", 30, 40)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/types.ts".to_string(),
exports: vec!["*".to_string()],
}]);
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"wildcard * should ignore all exports in matching file"
);
}
#[test]
fn unused_exports_ignore_specific_name_only() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![
make_export("ignored", 10, 20),
make_export("reported", 30, 40),
];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/utils.ts".to_string(),
exports: vec!["ignored".to_string()],
}]);
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "reported");
}
#[test]
fn unused_exports_ignore_rule_wrong_file_no_effect() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/other.ts".to_string(),
exports: vec!["*".to_string()],
}]);
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"ignore rule for different file should not suppress"
);
}
#[test]
fn unused_exports_no_plugin_result() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"None plugin_result means no plugin matchers"
);
}
#[test]
fn unused_exports_plugin_no_used_exports() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let pr = make_plugin_result(vec![]);
let (exports, _) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(
exports.len(),
1,
"plugin with no used_exports should not suppress"
);
}
#[test]
fn unused_exports_plugin_used_exports_suppresses() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/pages/index.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![
make_export("getStaticProps", 10, 20),
make_export("unusedHelper", 30, 40),
];
let config = test_config();
let suppressions = FxHashMap::default();
let pr = make_plugin_result(vec![(
"src/pages/**".to_string(),
vec!["getStaticProps".to_string()],
)]);
let (exports, _) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "unusedHelper");
}
#[test]
fn unused_exports_both_config_and_plugin_ignore() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/api/handler.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("handler", 10, 20)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/api/*.ts".to_string(),
exports: vec!["handler".to_string()],
}]);
let suppressions = FxHashMap::default();
let pr = make_plugin_result(vec![(
"src/api/**".to_string(),
vec!["handler".to_string()],
)]);
let (exports, _) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert!(
exports.is_empty(),
"export matching both config and plugin should be ignored"
);
}
#[test]
fn unused_exports_invalid_plugin_glob_skipped() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config();
let suppressions = FxHashMap::default();
let pr = make_plugin_result(vec![("[invalid".to_string(), vec!["foo".to_string()])]);
let (exports, _) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(exports.len(), 1, "invalid plugin glob should be skipped");
}
#[test]
fn unused_exports_marks_re_export_sentinel() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/barrel.ts", false),
]);
graph.modules[1].is_reachable = true;
graph.modules[1].exports = vec![make_export("reexported", 0, 0)];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, _) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert!(
exports[0].is_re_export,
"span 0..0 should be flagged as re-export"
);
}
#[test]
fn collect_usages_empty_graph() {
let graph = build_graph(&[]);
let result = collect_export_usages(&graph, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn collect_usages_skips_unreachable_modules() {
let mut graph = build_graph(&[("/src/dead.ts", false)]);
graph.modules[0].exports = vec![make_export("unused", 10, 20)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn collect_usages_skips_synthetic_exports() {
let mut graph = build_graph(&[("/src/barrel.ts", true)]);
graph.modules[0].exports = vec![make_export("reexported", 0, 0)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn collect_usages_counts_references() {
let mut graph = build_graph(&[("/src/utils.ts", true), ("/src/app.ts", false)]);
graph.modules[0].exports = vec![make_referenced_export("helper", 10, 20, 1)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "helper");
assert_eq!(result[0].reference_count, 1);
}
#[test]
fn collect_usages_zero_references_still_reported() {
let mut graph = build_graph(&[("/src/utils.ts", true)]);
graph.modules[0].exports = vec![make_export("unused", 10, 20)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "unused");
assert_eq!(result[0].reference_count, 0);
assert!(result[0].reference_locations.is_empty());
}
#[test]
fn collect_usages_multiple_exports_same_module() {
let mut graph = build_graph(&[("/src/utils.ts", true)]);
graph.modules[0].exports = vec![make_export("alpha", 10, 20), make_export("beta", 30, 40)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result.len(), 2);
let names: FxHashSet<&str> = result.iter().map(|u| u.export_name.as_str()).collect();
assert!(names.contains("alpha"));
assert!(names.contains("beta"));
}
#[test]
fn unused_exports_checks_unreachable_module_with_mixed_references() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/helpers.ts", false),
("/tmp/test/src/setup.ts", false),
]);
graph.modules[1].exports = vec![
ExportSymbol {
name: ExportName::Named("usedByUnreachable".to_string()),
is_type_only: false,
is_public: false,
span: Span::new(10, 30),
references: vec![SymbolReference {
from_file: FileId(2), kind: crate::graph::ReferenceKind::NamedImport,
import_span: Span::new(0, 10),
}],
members: vec![],
},
make_export("totallyUnused", 40, 55),
];
graph.modules[2].exports = vec![];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
let names: FxHashSet<&str> = exports.iter().map(|e| e.export_name.as_str()).collect();
assert!(
names.contains("usedByUnreachable"),
"reference from unreachable module should not save an export"
);
assert!(
names.contains("totallyUnused"),
"completely unreferenced export should be flagged"
);
assert_eq!(exports.len(), 2);
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_export_referenced_by_reachable() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/helpers.ts", false),
]);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("usedByReachable".to_string()),
is_type_only: false,
is_public: false,
span: Span::new(10, 28),
references: vec![SymbolReference {
from_file: FileId(0), kind: crate::graph::ReferenceKind::NamedImport,
import_span: Span::new(0, 10),
}],
members: vec![],
}];
let config = test_config();
let suppressions = FxHashMap::default();
let (exports, types) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"export referenced by reachable module should not be flagged"
);
assert!(types.is_empty());
}
}