use std::collections::{HashMap, HashSet};
use serde::Serialize;
use crate::types::{FileAnalysis, ReexportKind};
use super::root_scan::normalize_module_id;
type ReexportInfoEntry = (String, String, String);
type ReexportInfoMap = HashMap<(String, String), Vec<ReexportInfoEntry>>;
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct ShadowExport {
pub symbol: String,
pub used_file: String,
pub used_line: Option<usize>,
pub dead_files: Vec<ShadowExportFile>,
pub total_dead_loc: usize,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct ShadowExportFile {
pub file: String,
pub line: Option<usize>,
pub loc: usize,
}
mod filters;
mod languages;
pub mod output;
pub mod search;
pub use output::{
print_dead_exports, print_impact_results, print_shadow_exports, print_similarity_results,
print_symbol_results,
};
pub use search::{
ImpactResult, SimilarityCandidate, SymbolFileMatch, SymbolMatch, SymbolMatchKind,
SymbolSearchResult, analyze_impact, find_similar, search_symbol,
};
use filters::{
has_sys_modules_injection, is_ambient_export, is_dynamic_exec_template, is_flow_type_export,
is_jsx_runtime_export, is_python_test_export, is_python_test_path, is_weakmap_registry_export,
should_skip_dead_export_check,
};
use languages::{
crate_import_matches_file, is_in_python_all, is_python_dunder_method, is_python_library,
is_python_stdlib_export, is_rust_const_table, is_svelte_component_api, rust_has_known_derives,
};
fn strip_alias_prefix(path: &str) -> &str {
let without_at = path.trim_start_matches('@');
if let Some(idx) = without_at.find('/') {
&without_at[idx + 1..]
} else {
without_at
}
}
fn paths_match(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let a_norm = a.replace('\\', "/");
let b_norm = b.replace('\\', "/");
let a_clean = a_norm.trim_start_matches("./").to_string();
let b_clean = b_norm.trim_start_matches("./").to_string();
let (a_clean, b_clean) = if cfg!(windows) {
(a_clean.to_lowercase(), b_clean.to_lowercase())
} else {
(a_clean, b_clean)
};
if a_clean == b_clean {
return true;
}
let a_alias = strip_alias_prefix(&a_clean);
let b_alias = strip_alias_prefix(&b_clean);
if a_alias == b_clean || b_alias == a_clean || a_alias == b_alias {
return true;
}
let mod_a = normalize_module_id(&a_clean);
let mod_b = normalize_module_id(&b_clean);
if mod_a.path == mod_b.path || mod_a.as_key() == mod_b.as_key() {
return true;
}
if a_clean.len() > b_clean.len() {
if let Some(suffix_start) = a_clean.rfind(&b_clean) {
if suffix_start == 0 || a_clean.chars().nth(suffix_start - 1) == Some('/') {
return true;
}
}
} else if b_clean.len() > a_clean.len() {
if let Some(suffix_start) = b_clean.rfind(&a_clean) {
if suffix_start == 0 || b_clean.chars().nth(suffix_start - 1) == Some('/') {
return true;
}
}
}
false
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct DeadExport {
pub file: String,
pub symbol: String,
pub line: Option<usize>,
pub confidence: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub open_url: Option<String>,
#[serde(default)]
pub is_test: bool,
}
#[derive(Debug, Clone, Default)]
pub struct DeadFilterConfig {
pub include_tests: bool,
pub include_helpers: bool,
pub library_mode: bool,
pub example_globs: Vec<String>,
pub python_library_mode: bool,
pub include_ambient: bool,
pub include_dynamic: bool,
pub dead_ok_globs: Vec<String>,
}
pub fn find_dead_exports(
analyses: &[FileAnalysis],
high_confidence: bool,
open_base: Option<&str>,
config: DeadFilterConfig,
) -> Vec<DeadExport> {
let example_globset = if config.library_mode && !config.example_globs.is_empty() {
let mut builder = globset::GlobSetBuilder::new();
for pat in &config.example_globs {
match globset::Glob::new(pat) {
Ok(glob) => {
builder.add(glob);
}
Err(e) => {
eprintln!(
"[loctree][warn] invalid library_example_glob '{}': {}",
pat, e
);
}
}
}
builder.build().ok()
} else {
None
};
let dead_ok_globset = if !config.dead_ok_globs.is_empty() {
let mut builder = globset::GlobSetBuilder::new();
let mut any = false;
let mut add_glob = |pat: &str| {
let mut pat = pat.trim().replace('\\', "/");
if let Some(rest) = pat.strip_prefix("./") {
pat = rest.to_string();
}
if let Some(rest) = pat.strip_prefix('/') {
pat = rest.to_string();
}
if pat.is_empty() {
return;
}
match globset::Glob::new(&pat) {
Ok(glob) => {
builder.add(glob);
any = true;
}
Err(e) => {
eprintln!("[loctree][warn] invalid dead-ok glob '{}': {}", pat, e);
}
}
};
for pat in &config.dead_ok_globs {
let trimmed = pat.trim();
if trimmed.is_empty() {
continue;
}
if let Some(base) = trimmed.strip_suffix('/') {
if !base.is_empty() {
add_glob(base);
add_glob(&format!("{}/**", base));
}
} else {
add_glob(trimmed);
}
}
if any { builder.build().ok() } else { None }
} else {
None
};
let is_py_library = config.python_library_mode
&& analyses.iter().any(|a| {
a.path.ends_with(".py")
&& std::path::Path::new(&a.path)
.ancestors()
.any(is_python_library)
});
let analyses: Vec<&FileAnalysis> = analyses
.iter()
.filter(|a| !a.path.ends_with(".go"))
.collect();
let mut used_exports: HashSet<(String, String)> = HashSet::new();
let mut all_imported_symbols: HashSet<String> = HashSet::new();
let mut crate_internal_imports: Vec<(String, String)> = Vec::new();
let mut import_counts: HashMap<(String, String), usize> = HashMap::new();
let mut reexport_info: ReexportInfoMap = HashMap::new();
let mut dynamic_import_sources: HashMap<String, Vec<String>> = HashMap::new();
for analysis in &analyses {
for imp in &analysis.imports {
let target_norm = if let Some(target) = &imp.resolved_path {
normalize_module_id(target).as_key()
} else {
normalize_module_id(&imp.source).as_key()
};
for sym in &imp.symbols {
let used_name = if sym.is_default {
"default".to_string()
} else {
sym.name.clone()
};
used_exports.insert((target_norm.clone(), used_name.clone()));
if !used_name.is_empty() {
all_imported_symbols.insert(used_name.clone());
}
if imp.is_crate_relative || imp.is_super_relative || imp.is_self_relative {
crate_internal_imports.push((imp.raw_path.clone(), used_name.clone()));
}
*import_counts
.entry((target_norm.clone(), used_name))
.or_insert(0) += 1;
}
}
for dyn_imp in &analysis.dynamic_imports {
let dyn_norm = normalize_module_id(dyn_imp).as_key();
dynamic_import_sources
.entry(dyn_norm)
.or_default()
.push(analysis.path.clone());
}
for re in &analysis.reexports {
let target_norm = re
.resolved
.as_ref()
.map(|t| normalize_module_id(t).as_key())
.unwrap_or_else(|| normalize_module_id(&re.source).as_key());
match &re.kind {
ReexportKind::Star => {
used_exports.insert((target_norm, "*".to_string()));
}
ReexportKind::Named(names) => {
for (original, exported) in names {
used_exports.insert((target_norm.clone(), original.clone()));
reexport_info
.entry((target_norm.clone(), original.clone()))
.or_default()
.push((analysis.path.clone(), original.clone(), exported.clone()));
}
}
}
}
}
let dts_reexports: Vec<_> = analyses
.iter()
.filter(|a| {
a.path.ends_with(".d.ts") || a.path.ends_with(".d.mts") || a.path.ends_with(".d.cts")
})
.flat_map(|a| &a.reexports)
.collect();
for re in dts_reexports {
let target_norm = re
.resolved
.as_ref()
.map(|t| normalize_module_id(t).as_key())
.unwrap_or_else(|| normalize_module_id(&re.source).as_key());
match &re.kind {
ReexportKind::Star => {
used_exports.insert((target_norm, "*".to_string()));
}
ReexportKind::Named(names) => {
for (original, _exported) in names {
used_exports.insert((target_norm.clone(), original.clone()));
}
}
}
}
let tauri_handlers: HashSet<String> = analyses
.iter()
.flat_map(|a| a.tauri_registered_handlers.iter().cloned())
.collect();
let mut go_local_uses_by_dir: HashMap<String, HashSet<String>> = HashMap::new();
for analysis in analyses.iter().filter(|a| a.path.ends_with(".go")) {
if let Some(dir) = std::path::Path::new(&analysis.path)
.parent()
.map(|p| p.to_string_lossy().to_string())
{
go_local_uses_by_dir
.entry(dir)
.or_default()
.extend(analysis.local_uses.iter().cloned());
}
}
let rust_path_qualified_symbols: HashSet<String> = analyses
.iter()
.filter(|a| a.path.ends_with(".rs"))
.flat_map(|a| a.local_uses.iter().cloned())
.collect();
let dynamically_reachable: HashSet<String> = {
let mut import_graph: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for analysis in &analyses {
let key = normalize_module_id(&analysis.path).as_key();
let imports: Vec<String> = analysis
.imports
.iter()
.filter_map(|imp| imp.resolved_path.as_ref())
.map(|p| normalize_module_id(p).as_key())
.collect();
import_graph.insert(key, imports);
}
let mut reachable: HashSet<String> = HashSet::new();
for analysis in &analyses {
for imp in &analysis.imports {
if matches!(imp.kind, crate::types::ImportKind::Dynamic) {
if let Some(resolved) = &imp.resolved_path {
let resolved_key = normalize_module_id(resolved).as_key();
reachable.insert(resolved_key);
}
let source_norm = normalize_module_id(&imp.source);
let source_key = source_norm.as_key();
let source_alias = strip_alias_prefix(&source_norm.path).to_string();
for a in &analyses {
let a_norm = normalize_module_id(&a.path);
let a_key = a_norm.as_key();
if paths_match(&imp.source, &a_norm.path)
|| paths_match(&imp.source, &a.path)
|| a_norm.path.ends_with(&source_alias)
{
reachable.insert(a_key);
break;
}
}
reachable.insert(source_key);
if !source_alias.is_empty() {
reachable.insert(source_alias);
}
}
}
}
for analysis in &analyses {
for dyn_imp in &analysis.dynamic_imports {
let dyn_norm = normalize_module_id(dyn_imp);
let dyn_key = dyn_norm.as_key();
let dyn_alias = strip_alias_prefix(&dyn_norm.path).to_string();
for a in &analyses {
let a_norm = normalize_module_id(&a.path);
let a_key = a_norm.as_key();
if paths_match(dyn_imp, &a_norm.path)
|| paths_match(dyn_imp, &a.path)
|| a_norm.path.starts_with(&dyn_norm.path)
|| a_norm.path.starts_with(&dyn_alias)
|| a_norm.path.ends_with(&dyn_alias)
{
reachable.insert(a_key);
break;
}
}
reachable.insert(dyn_key.clone());
if !dyn_alias.is_empty() {
reachable.insert(dyn_alias.clone());
}
}
}
let mut queue: std::collections::VecDeque<String> = reachable.iter().cloned().collect();
while let Some(current) = queue.pop_front() {
if let Some(imports) = import_graph.get(¤t) {
for imp in imports {
if !reachable.contains(imp) {
reachable.insert(imp.clone());
queue.push_back(imp.clone());
}
}
}
}
reachable
};
let mut dead_candidates = Vec::new();
for analysis in analyses {
if should_skip_dead_export_check(analysis, &config, example_globset.as_ref()) {
continue;
}
let is_crate_root = analysis.path == "lib.rs"
|| analysis.path == "main.rs"
|| analysis.path.ends_with("/lib.rs")
|| analysis.path.ends_with("/main.rs");
if is_crate_root {
continue;
}
if is_rust_const_table(analysis) {
continue;
}
let path_norm = normalize_module_id(&analysis.path).as_key();
let is_go_file = analysis.path.ends_with(".go");
if is_go_file
&& (analysis.path.ends_with(".pb.go")
|| analysis.path.ends_with(".pb.gw.go")
|| analysis.path.contains(".pb.")
|| analysis.path.contains(".pbjson"))
{
continue;
}
if is_go_file {
continue;
}
if dynamically_reachable.contains(&path_norm) {
continue;
}
let local_uses: HashSet<_> = analysis.local_uses.iter().cloned().collect();
for exp in &analysis.exports {
let is_rust_file = analysis.path.ends_with(".rs");
if exp.kind == "reexport" {
continue;
}
let rust_macro_marked = is_rust_file
&& rust_has_known_derives(
&analysis.path,
&[
"serialize",
"deserialize",
"parser",
"args",
"valueenum",
"subcommand",
"fromargmatches",
],
);
let rust_cli_pattern = is_rust_file
&& (exp.name.ends_with("Args")
|| exp.name.ends_with("Command")
|| exp.name.ends_with("Response")
|| exp.name.ends_with("Request"));
if rust_macro_marked || rust_cli_pattern {
continue;
}
let is_python_file = analysis.path.ends_with(".py");
let python_framework_magic = is_python_file
&& (
exp.name == "WorkerSettings"
|| exp.name == "__version__"
|| (analysis.path.contains("conftest") && exp.kind == "def")
);
if python_framework_magic {
continue;
}
let is_stdlib = is_python_file && is_python_stdlib_export(analysis, &exp.name);
if (is_py_library || is_stdlib) && is_python_file {
if is_in_python_all(analysis, &exp.name) {
continue;
}
if is_stdlib {
continue;
}
if is_python_dunder_method(&exp.name) {
continue;
}
}
let is_django_mixin =
is_python_file && exp.kind == "class" && exp.name.ends_with("Mixin");
if is_django_mixin {
continue;
}
if is_python_test_export(analysis, exp) || is_python_test_path(&analysis.path) {
continue;
}
if exp.name == "default"
&& (analysis.path.ends_with("page.tsx") || analysis.path.ends_with("layout.tsx"))
{
continue;
}
let is_ts_file = analysis.path.ends_with(".ts")
|| analysis.path.ends_with(".tsx")
|| analysis.path.ends_with(".js")
|| analysis.path.ends_with(".jsx")
|| analysis.path.ends_with(".mjs")
|| analysis.path.ends_with(".cjs");
let ts_runtime_symbol = is_ts_file
&& (matches!(
exp.name.as_str(),
"jsx" | "jsxs" | "jsxDEV" | "Fragment" | "VoidComponent" | "Component"
) || analysis.path.contains("jsx-runtime"));
let ts_framework_magic = is_ts_file
&& (matches!(
exp.name.as_str(),
"start" | "resolveRoute" | "enhance" | "load" | "PageLoad" | "LayoutLoad"
) || analysis.path.contains("sveltekit")
|| analysis.path.contains("app/navigation"));
if ts_runtime_symbol || ts_framework_magic {
continue;
}
if high_confidence && exp.name == "default" {
continue;
}
let is_used = used_exports.contains(&(path_norm.clone(), exp.name.clone()));
let star_used = used_exports.contains(&(path_norm.clone(), "*".to_string()));
let locally_used = local_uses.contains(&exp.name);
let go_pkg_used = if analysis.path.ends_with(".go") {
std::path::Path::new(&analysis.path)
.parent()
.and_then(|p| go_local_uses_by_dir.get(&p.to_string_lossy().to_string()))
.is_some_and(|set| set.contains(&exp.name))
} else {
false
};
let is_tauri_handler = tauri_handlers.contains(&exp.name);
let imported_by_name = all_imported_symbols.contains(&exp.name);
let is_svelte_api = is_svelte_component_api(&analysis.path, &exp.name);
let is_rust_path_qualified =
analysis.path.ends_with(".rs") && rust_path_qualified_symbols.contains(&exp.name);
let crate_import_count = crate_internal_imports
.iter()
.filter(|(raw_path, symbol)| {
let symbol_matches = symbol == &exp.name
|| symbol.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_')
== exp.name
|| symbol.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '_')
== exp.name;
symbol_matches && crate_import_matches_file(raw_path, &analysis.path, &exp.name)
})
.count();
let is_crate_imported = crate_import_count > 0;
let is_jsx_runtime = is_jsx_runtime_export(&exp.name, &analysis.path);
let is_flow_type = is_flow_type_export(exp, analysis);
let is_weak_registry = is_weakmap_registry_export(exp, analysis);
let is_ambient = !config.include_ambient && is_ambient_export(exp, analysis);
let is_dynamic_generated =
!config.include_dynamic && is_dynamic_exec_template(&exp.name, analysis);
let is_sys_modules_injected =
!config.include_dynamic && has_sys_modules_injection(analysis);
let is_dynamically_imported_default =
exp.export_type == "default" && dynamic_import_sources.contains_key(&path_norm);
if !is_used
&& !star_used
&& !locally_used
&& !go_pkg_used
&& !is_tauri_handler
&& !imported_by_name
&& !is_svelte_api
&& !is_rust_path_qualified
&& !is_crate_imported
&& !is_jsx_runtime
&& !is_flow_type
&& !is_weak_registry
&& !is_ambient
&& !is_dynamic_generated
&& !is_sys_modules_injected
&& !is_dynamically_imported_default
{
let open_url = super::build_open_url(&analysis.path, exp.line, open_base);
let import_count = import_counts
.get(&(path_norm.clone(), exp.name.clone()))
.copied()
.unwrap_or(0);
let reexport_entries = reexport_info
.get(&(path_norm.clone(), exp.name.clone()))
.cloned()
.unwrap_or_default();
let reexport_count = reexport_entries.len();
let dynamic_count = dynamic_import_sources
.get(&path_norm)
.map(|v| v.len())
.unwrap_or(0);
let reason = if is_rust_file {
format!(
"Exported symbol '{}' has no detected usages. \
Checked: use statements ({}), path-qualified calls (0), \
crate:: imports ({}), Tauri invoke_handler (not found). \
Consider: If this is a public API consumed externally, it's expected. \
If internal-only, consider removing or making private.",
exp.name, import_count, crate_import_count
)
} else {
let reexport_details = if !reexport_entries.is_empty() {
let details: Vec<String> = reexport_entries
.iter()
.take(3) .map(|(file, original, alias)| {
if original != alias {
format!("as '{}' in {}", alias, file)
} else {
file.clone()
}
})
.collect();
let more = if reexport_entries.len() > 3 {
format!(" (+{} more)", reexport_entries.len() - 3)
} else {
String::new()
};
format!(" ({}{})", details.join(", "), more)
} else {
String::new()
};
let dynamic_details = if dynamic_count > 0 {
let sources: Vec<String> = dynamic_import_sources
.get(&path_norm)
.map(|v| v.iter().take(2).cloned().collect())
.unwrap_or_default();
let more = if dynamic_count > 2 {
format!(" +{} more", dynamic_count - 2)
} else {
String::new()
};
format!(" (by {}{})", sources.join(", "), more)
} else {
String::new()
};
format!(
"Exported symbol '{}' has no detected imports. \
Checked: import statements ({}), re-exports ({}){}, \
dynamic imports ({}){}, JSX references (0). \
Consider: If used via barrel exports or external packages, verify manually. \
If truly unused, safe to remove.",
exp.name,
import_count,
reexport_count,
reexport_details,
dynamic_count,
dynamic_details
)
};
dead_candidates.push(DeadExport {
file: analysis.path.clone(),
symbol: exp.name.clone(),
line: exp.line,
confidence: if high_confidence {
"very-high".to_string()
} else {
"high".to_string()
},
reason,
open_url: Some(open_url),
is_test: analysis.is_test,
});
}
}
}
if let Some(globs) = dead_ok_globset {
dead_candidates.retain(|d| {
let lower = d.file.to_ascii_lowercase();
!globs.is_match(&d.file) && !globs.is_match(&lower)
});
}
dead_candidates
}
pub fn find_shadow_exports(analyses: &[FileAnalysis]) -> Vec<ShadowExport> {
let mut symbol_map: HashMap<String, Vec<(String, Option<usize>, String)>> = HashMap::new();
for analysis in analyses {
for exp in &analysis.exports {
if exp.kind == "reexport" {
continue;
}
symbol_map.entry(exp.name.clone()).or_default().push((
analysis.path.clone(),
exp.line,
exp.kind.clone(),
));
}
}
let mut used_exports: HashSet<(String, String)> = HashSet::new();
for analysis in analyses {
for imp in &analysis.imports {
let target_norm = if let Some(target) = &imp.resolved_path {
normalize_module_id(target).as_key()
} else {
normalize_module_id(&imp.source).as_key()
};
for sym in &imp.symbols {
let used_name = if sym.is_default {
"default".to_string()
} else {
sym.name.clone()
};
used_exports.insert((target_norm.clone(), used_name));
}
}
for re in &analysis.reexports {
let target_norm = re
.resolved
.as_ref()
.map(|t| normalize_module_id(t).as_key())
.unwrap_or_else(|| normalize_module_id(&re.source).as_key());
match &re.kind {
ReexportKind::Star => {
used_exports.insert((target_norm, "*".to_string()));
}
ReexportKind::Named(names) => {
for (original, _exported) in names {
used_exports.insert((target_norm.clone(), original.clone()));
}
}
}
}
}
let mut shadows = Vec::new();
for (symbol, exporters) in symbol_map {
if exporters.len() <= 1 {
continue; }
let mut used_files = Vec::new();
let mut dead_files = Vec::new();
for (file, line, _kind) in &exporters {
let file_norm = normalize_module_id(file).as_key();
let is_used = used_exports.contains(&(file_norm.clone(), symbol.clone()))
|| used_exports.contains(&(file_norm, "*".to_string()));
if is_used {
used_files.push((file.clone(), *line));
} else {
let loc = analyses
.iter()
.find(|a| a.path == *file)
.map(|a| a.loc)
.unwrap_or(0);
dead_files.push(ShadowExportFile {
file: file.clone(),
line: *line,
loc,
});
}
}
if !used_files.is_empty() && !dead_files.is_empty() {
let (used_file, used_line) = used_files.into_iter().next().unwrap();
let total_dead_loc = dead_files.iter().map(|f| f.loc).sum();
shadows.push(ShadowExport {
symbol,
used_file,
used_line,
dead_files,
total_dead_loc,
});
}
}
shadows.sort_by(|a, b| b.total_dead_loc.cmp(&a.total_dead_loc));
shadows
}
#[cfg(test)]
mod tests {
use super::*;
use crate::OutputMode;
use crate::types::{
ExportSymbol, ImportEntry, ImportKind, ImportSymbol, ReexportEntry, ReexportKind,
SymbolMatch as TypesSymbolMatch,
};
fn mock_file(path: &str) -> FileAnalysis {
FileAnalysis {
path: path.to_string(),
..Default::default()
}
}
fn mock_file_with_exports(path: &str, exports: Vec<&str>) -> FileAnalysis {
FileAnalysis {
path: path.to_string(),
exports: exports
.into_iter()
.enumerate()
.map(|(i, name)| ExportSymbol {
name: name.to_string(),
kind: "function".to_string(),
export_type: "named".to_string(),
line: Some(i + 1),
params: Vec::new(),
})
.collect(),
..Default::default()
}
}
fn mock_file_with_matches(path: &str, matches: Vec<(usize, &str)>) -> FileAnalysis {
FileAnalysis {
path: path.to_string(),
matches: matches
.into_iter()
.map(|(line, ctx)| TypesSymbolMatch {
line,
context: ctx.to_string(),
})
.collect(),
..Default::default()
}
}
#[test]
fn test_search_symbol_empty() {
let analyses: Vec<FileAnalysis> = vec![];
let result = search_symbol("foo", &analyses);
assert!(!result.found);
assert!(result.files.is_empty());
}
#[test]
fn test_search_symbol_no_matches() {
let analyses = vec![mock_file("src/utils.ts"), mock_file("src/helpers.ts")];
let result = search_symbol("foo", &analyses);
assert!(!result.found);
}
#[test]
fn test_search_symbol_with_matches() {
let analyses = vec![
mock_file_with_matches(
"src/utils.ts",
vec![(10, "const foo = 1"), (20, "return foo")],
),
mock_file("src/helpers.ts"),
];
let result = search_symbol("foo", &analyses);
assert!(result.found);
assert_eq!(result.files.len(), 1);
}
#[test]
fn test_find_dead_exports_respects_from_imports() {
let exporter = mock_file_with_exports("pkg/module.py", vec!["Foo"]);
let mut importer = mock_file("main.py");
let mut imp = ImportEntry::new("pkg.module".to_string(), ImportKind::Static);
imp.resolved_path = Some("pkg/module.py".to_string());
imp.symbols.push(ImportSymbol {
name: "Foo".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"export imported with explicit symbol should not be dead"
);
}
#[test]
fn test_find_dead_exports_respects_local_usage() {
let mut file = mock_file_with_exports("app.py", vec!["refresh"]);
file.local_uses.push("refresh".to_string());
let result = find_dead_exports(&[file], false, None, DeadFilterConfig::default());
assert!(
result.is_empty(),
"locally referenced export should not be marked dead"
);
}
#[test]
fn test_find_dead_exports_respects_type_imports() {
let exporter = mock_file_with_exports("client/actions.ts", vec!["Action"]);
let mut importer = mock_file("client/state.ts");
let mut imp = ImportEntry::new("client/actions".to_string(), ImportKind::Type);
imp.resolved_path = Some("client/actions.ts".to_string());
imp.symbols.push(ImportSymbol {
name: "Action".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let result = find_dead_exports(
&[importer, exporter],
true,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"type-only import should count as usage for dead export detection"
);
}
#[test]
fn test_find_dead_exports_cross_extension_match() {
let exporter = mock_file_with_exports("src/ComboBox.tsx", vec!["ComboBox"]);
let mut importer = mock_file("src/app.js");
let mut imp = ImportEntry::new("./ComboBox".to_string(), ImportKind::Static);
imp.resolved_path = Some("src/ComboBox.tsx".to_string());
imp.symbols.push(ImportSymbol {
name: "ComboBox".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"imports across JS/TSX extensions should prevent dead export marking"
);
}
#[test]
fn test_find_dead_exports_respects_crate_imports() {
let exporter = mock_file_with_exports("src/ui/constants.rs", vec!["MENU_GAP"]);
let mut importer = mock_file("src/main.rs");
let mut imp = ImportEntry::new(
"crate::ui::constants::MENU_GAP".to_string(),
ImportKind::Static,
);
imp.raw_path = "crate::ui::constants::MENU_GAP".to_string();
imp.is_crate_relative = true;
imp.symbols.push(ImportSymbol {
name: "MENU_GAP".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"crate-internal imports should prevent dead export marking. Found: {:?}",
result
);
}
#[test]
fn test_find_dead_exports_respects_super_imports() {
let exporter = mock_file_with_exports("src/types.rs", vec!["Config"]);
let mut importer = mock_file("src/ui/widget.rs");
let mut imp = ImportEntry::new("super::types::Config".to_string(), ImportKind::Static);
imp.raw_path = "super::types::Config".to_string();
imp.is_super_relative = true;
imp.symbols.push(ImportSymbol {
name: "Config".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"super:: imports should prevent dead export marking. Found: {:?}",
result
);
}
#[test]
fn test_crate_import_matches_file_basic() {
assert!(
crate_import_matches_file(
"crate::ui::constants::MENU_GAP",
"src/ui/constants.rs",
"MENU_GAP"
),
"should match crate::ui::constants with src/ui/constants.rs"
);
assert!(
crate_import_matches_file("crate::types::Config", "src/types.rs", "Config"),
"should match crate::types with src/types.rs"
);
assert!(
crate_import_matches_file("super::utils::helper", "utils.rs", "helper"),
"should match super::utils with utils.rs"
);
assert!(
!crate_import_matches_file("crate::ui::constants::X", "src/ui/layout.rs", "X"),
"should NOT match constants with layout.rs"
);
assert!(
!crate_import_matches_file("external::package::Foo", "src/foo.rs", "Foo"),
"should NOT match non-crate imports"
);
}
#[test]
fn test_print_symbol_results_no_matches() {
let result = SymbolSearchResult {
found: false,
total_matches: 0,
files: vec![],
};
print_symbol_results("foo", &result, false);
print_symbol_results("foo", &result, true);
}
#[test]
fn test_print_symbol_results_with_matches() {
let result = SymbolSearchResult {
found: true,
total_matches: 1,
files: vec![SymbolFileMatch {
file: "src/utils.ts".to_string(),
matches: vec![SymbolMatch {
line: 10,
context: "const foo = 1".to_string(),
is_definition: true,
kind: SymbolMatchKind::Definition,
}],
}],
};
print_symbol_results("foo", &result, false);
print_symbol_results("foo", &result, true);
}
#[test]
fn test_find_similar_empty() {
let analyses: Vec<FileAnalysis> = vec![];
let result = find_similar("Button", &analyses);
assert!(result.is_empty());
}
#[test]
fn test_find_similar_by_path() {
let analyses = vec![mock_file("Button.tsx"), mock_file("src/utils/helpers.ts")];
let result = find_similar("Button", &analyses);
assert!(!result.is_empty());
assert!(result.iter().any(|c| c.symbol.contains("Button")));
}
#[test]
fn test_find_similar_by_export() {
let analyses = vec![mock_file_with_exports(
"src/utils.ts",
vec!["useButton", "formatDate"],
)];
let result = find_similar("Button", &analyses);
assert!(result.iter().any(|c| c.symbol == "useButton"));
}
#[test]
fn test_print_similarity_results_empty() {
let candidates: Vec<SimilarityCandidate> = vec![];
print_similarity_results("foo", &candidates, false);
print_similarity_results("foo", &candidates, true);
}
#[test]
fn test_print_similarity_results_with_matches() {
let candidates = vec![SimilarityCandidate {
symbol: "fooBar".to_string(),
file: "export in src/utils.ts".to_string(),
score: 0.8,
}];
print_similarity_results("foo", &candidates, false);
print_similarity_results("foo", &candidates, true);
}
#[test]
fn test_find_dead_exports_empty() {
let analyses: Vec<FileAnalysis> = vec![];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert!(result.is_empty());
}
#[test]
fn test_find_dead_exports_all_used() {
let mut importer = mock_file("src/app.ts");
importer.imports = vec![{
let mut imp = ImportEntry::new("./utils".to_string(), ImportKind::Static);
imp.resolved_path = Some("src/utils.ts".to_string());
imp.symbols = vec![ImportSymbol {
name: "helper".to_string(),
alias: None,
is_default: false,
}];
imp
}];
let exporter = mock_file_with_exports("src/utils.ts", vec!["helper"]);
let analyses = vec![importer, exporter];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert!(result.is_empty());
}
#[test]
fn test_find_dead_exports_unused() {
let analyses = vec![
mock_file("src/app.ts"),
mock_file_with_exports("src/utils.ts", vec!["unusedHelper"]),
];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].symbol, "unusedHelper");
}
#[test]
fn test_find_dead_exports_dead_ok_glob_suppresses() {
let analyses = vec![
mock_file("src/app.ts"),
mock_file_with_exports("src/generated/utils.ts", vec!["unusedHelper"]),
];
let result = find_dead_exports(
&analyses,
false,
None,
DeadFilterConfig {
include_tests: false,
include_helpers: false,
library_mode: false,
example_globs: Vec::new(),
python_library_mode: false,
include_ambient: false,
include_dynamic: false,
dead_ok_globs: vec!["src/generated/**".to_string()],
},
);
assert!(
result.is_empty(),
"dead-ok glob should suppress dead exports for matching files: {:?}",
result
);
}
#[test]
fn test_find_dead_exports_skips_tests() {
let mut test_file =
mock_file_with_exports("src/__tests__/utils.test.ts", vec!["testHelper"]);
test_file.is_test = true;
let analyses = vec![mock_file("src/app.ts"), test_file];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert!(result.is_empty());
}
#[test]
fn test_find_dead_exports_includes_tests_when_requested() {
let mut test_file =
mock_file_with_exports("src/__tests__/utils.test.ts", vec!["testHelper"]);
test_file.is_test = true;
let analyses = vec![mock_file("src/app.ts"), test_file];
let result = find_dead_exports(
&analyses,
false,
None,
DeadFilterConfig {
include_tests: true,
include_helpers: false,
library_mode: false,
example_globs: Vec::new(),
python_library_mode: false,
include_ambient: false,
include_dynamic: false,
dead_ok_globs: Vec::new(),
},
);
assert_eq!(result.len(), 1);
assert_eq!(result[0].symbol, "testHelper");
}
#[test]
fn test_find_dead_exports_skips_helpers_by_default() {
let helper = mock_file_with_exports("scripts/cleanup.py", vec!["orphan"]);
let analyses = vec![mock_file("src/app.ts"), helper];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert!(result.is_empty(), "helper scripts should be skipped");
}
#[test]
fn test_find_dead_exports_skips_jsx_runtime_files() {
let mut jsx_runtime = mock_file_with_exports(
"packages/solid-js/jsx-runtime/index.ts",
vec!["jsx", "jsxs", "jsxDEV", "Fragment"],
);
jsx_runtime.language = "ts".to_string();
let result = find_dead_exports(&[jsx_runtime], false, None, DeadFilterConfig::default());
assert!(
result.is_empty(),
"JSX runtime files should be completely skipped: {:?}",
result
);
}
#[test]
fn test_find_dead_exports_skips_jsx_runtime_exports() {
let mut runtime_file = mock_file_with_exports(
"node_modules/solid-js/jsx-runtime.js",
vec!["jsx", "jsxs", "jsxDEV", "Fragment", "createComponent"],
);
runtime_file.language = "js".to_string();
let result = find_dead_exports(&[runtime_file], false, None, DeadFilterConfig::default());
assert!(
result.is_empty(),
"JSX runtime exports should not be flagged as dead: {:?}",
result
);
}
#[test]
fn test_jsx_runtime_export_detection() {
assert!(is_jsx_runtime_export(
"jsx",
"packages/solid-js/jsx-runtime/index.ts"
));
assert!(is_jsx_runtime_export("jsxs", "vue/jsx-runtime.js"));
assert!(is_jsx_runtime_export("jsxDEV", "react/jsx-dev-runtime.js"));
assert!(is_jsx_runtime_export(
"Fragment",
"preact/jsx-runtime/index.mjs"
));
assert!(is_jsx_runtime_export("jsxsDEV", "solid/jsx_runtime.ts"));
assert!(!is_jsx_runtime_export("Component", "jsx-runtime/index.ts"));
assert!(!is_jsx_runtime_export("jsx", "src/utils/helpers.ts"));
assert!(!is_jsx_runtime_export("createElement", "jsx-runtime.js"));
}
#[test]
fn test_find_dead_exports_high_confidence_skips_default() {
let analyses = vec![
mock_file("src/app.ts"),
mock_file_with_exports("src/utils.ts", vec!["default", "helper"]),
];
let result = find_dead_exports(&analyses, true, None, DeadFilterConfig::default());
assert!(!result.iter().any(|d| d.symbol == "default"));
}
#[test]
fn test_find_dead_exports_skips_dynamic_import_without_extension() {
let mut importer = mock_file("src/app.tsx");
importer.dynamic_imports = vec!["./utils".to_string()];
let exporter = mock_file_with_exports("src/utils/index.ts", vec!["foo"]);
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"dynamic import should mark module as used"
);
}
#[test]
fn test_dynamic_import_with_alias_prefix_marks_reachable() {
let mut importer = mock_file("src/app.ts");
importer.dynamic_imports = vec!["@core/utils".to_string()];
let exporter = mock_file_with_exports("src/core/utils/index.ts", vec!["helper"]);
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"alias-prefixed dynamic import should keep target reachable"
);
}
#[test]
fn test_find_dead_exports_counts_default_import_usage() {
let mut importer = mock_file("src/app.ts");
importer.imports = vec![{
let mut imp = ImportEntry::new("./utils".to_string(), ImportKind::Static);
imp.resolved_path = Some("src/utils.ts".to_string());
imp.symbols = vec![ImportSymbol {
name: "AliasDefault".to_string(),
alias: None,
is_default: true,
}];
imp
}];
let mut exporter = mock_file_with_exports("src/utils.ts", vec!["default"]);
exporter.exports[0].kind = "default".to_string();
exporter.exports[0].export_type = "default".to_string();
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"default import should mark export as used"
);
}
#[test]
fn test_react_lazy_default_export_not_dead() {
let mut importer = mock_file("src/App.tsx");
importer.dynamic_imports = vec!["./PasswordResetModal".to_string()];
let mut exporter = mock_file("src/PasswordResetModal.tsx");
exporter.exports = vec![ExportSymbol {
name: "PasswordResetModal".to_string(),
kind: "function".to_string(),
export_type: "default".to_string(),
line: Some(23),
params: Vec::new(),
}];
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"React lazy() default export should NOT be marked as dead: {:?}",
result
);
}
#[test]
fn test_react_lazy_with_subdirectory_resolved_path() {
let mut importer = mock_file("src/App.tsx");
let mut dyn_import = ImportEntry::new(
"./features/patient/PasswordResetModal".to_string(),
ImportKind::Dynamic,
);
dyn_import.resolved_path = Some("src/features/patient/PasswordResetModal.tsx".to_string());
importer.imports.push(dyn_import);
importer.dynamic_imports = vec!["./features/patient/PasswordResetModal".to_string()];
let mut exporter = mock_file("src/features/patient/PasswordResetModal.tsx");
exporter.exports = vec![ExportSymbol {
name: "PasswordResetModal".to_string(),
kind: "function".to_string(),
export_type: "default".to_string(),
line: Some(23),
params: Vec::new(),
}];
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"React lazy() with resolved_path in subdirectory should NOT be marked as dead: {:?}",
result
);
}
#[test]
fn test_react_lazy_named_export_via_then_pattern() {
let mut importer = mock_file("src/App.tsx");
let mut dyn_import =
ImportEntry::new("./features/DashboardView".to_string(), ImportKind::Dynamic);
dyn_import.resolved_path = Some("src/features/DashboardView.tsx".to_string());
importer.imports.push(dyn_import);
let mut exporter = mock_file("src/features/DashboardView.tsx");
exporter.exports = vec![ExportSymbol {
name: "DashboardView".to_string(),
kind: "function".to_string(),
export_type: "named".to_string(),
line: Some(15),
params: Vec::new(),
}];
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"Dynamic import with .then() pattern should skip entire file: {:?}",
result
);
}
#[test]
fn test_find_dead_exports_skips_reexport_bindings() {
let mut barrel = mock_file_with_exports("src/index.ts", vec!["Foo"]);
if let Some(first) = barrel.exports.first_mut() {
first.kind = "reexport".to_string();
}
barrel.reexports.push(ReexportEntry {
source: "./foo".to_string(),
kind: ReexportKind::Named(vec![("Foo".to_string(), "Foo".to_string())]),
resolved: Some("src/foo.ts".to_string()),
});
let result = find_dead_exports(&[barrel], false, None, DeadFilterConfig::default());
assert!(
result.is_empty(),
"reexport-only barrels should not be reported as dead exports"
);
}
#[test]
fn test_print_dead_exports_json() {
let dead = vec![DeadExport {
file: "src/utils.ts".to_string(),
symbol: "unused".to_string(),
line: Some(10),
confidence: "high".to_string(),
reason: "No imports found for 'unused'. Checked: resolved imports (0 matches), star re-exports (none), local references (none)".to_string(),
open_url: Some("loctree://open?f=src%2Futils.ts&l=10".to_string()),
is_test: false,
}];
print_dead_exports(&dead, OutputMode::Json, false, 20);
}
#[test]
fn test_print_dead_exports_human() {
let dead = vec![DeadExport {
file: "src/utils.ts".to_string(),
symbol: "unused".to_string(),
line: None,
confidence: "high".to_string(),
reason: "No imports found for 'unused'. Checked: resolved imports (0 matches), star re-exports (none), local references (none)".to_string(),
open_url: None,
is_test: false,
}];
print_dead_exports(&dead, OutputMode::Human, false, 20);
print_dead_exports(&dead, OutputMode::Human, true, 20);
}
#[test]
fn test_print_dead_exports_many() {
let dead: Vec<DeadExport> = (0..60)
.map(|i| DeadExport {
file: format!("src/file{}.ts", i),
symbol: format!("unused{}", i),
line: Some(i),
confidence: "high".to_string(),
reason: format!("No imports found for 'unused{}'. Checked: resolved imports (0 matches), star re-exports (none), local references (none)", i),
open_url: Some(format!("loctree://open?f=src%2Ffile{}.ts&l={}", i, i)),
is_test: false,
})
.collect();
print_dead_exports(&dead, OutputMode::Human, false, 50);
}
#[test]
fn test_django_wagtail_mixin_not_dead() {
use crate::types::{ExportSymbol, FileAnalysis};
let mixin_file = FileAnalysis {
path: "myapp/mixins.py".to_string(),
language: "py".to_string(),
exports: vec![
ExportSymbol::new("LoginRequiredMixin".to_string(), "class", "named", Some(1)),
ExportSymbol::new(
"PermissionRequiredMixin".to_string(),
"class",
"named",
Some(5),
),
ExportSymbol::new("ButtonsColumnMixin".to_string(), "class", "named", Some(10)),
],
..Default::default()
};
let mut view_file = FileAnalysis {
path: "myapp/views.py".to_string(),
language: "py".to_string(),
exports: vec![], local_uses: vec![
"LoginRequiredMixin".to_string(),
"PermissionRequiredMixin".to_string(),
"ButtonsColumnMixin".to_string(),
],
..Default::default()
};
use crate::types::{ImportEntry, ImportKind, ImportSymbol};
let mut imp = ImportEntry::new("myapp.mixins".to_string(), ImportKind::Static);
imp.resolved_path = Some("myapp/mixins.py".to_string());
imp.symbols = vec![
ImportSymbol {
name: "LoginRequiredMixin".to_string(),
alias: None,
is_default: false,
},
ImportSymbol {
name: "PermissionRequiredMixin".to_string(),
alias: None,
is_default: false,
},
ImportSymbol {
name: "ButtonsColumnMixin".to_string(),
alias: None,
is_default: false,
},
];
view_file.imports.push(imp);
let analyses = vec![mixin_file, view_file];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert!(
result.is_empty(),
"Django/Wagtail mixins should not be marked as dead. Found dead: {:?}",
result
);
}
#[test]
fn test_django_mixin_pattern_common_names() {
use crate::types::{ExportSymbol, FileAnalysis};
let mixin_file = FileAnalysis {
path: "django/contrib/auth/mixins.py".to_string(),
language: "py".to_string(),
exports: vec![
ExportSymbol::new("LoginRequiredMixin".to_string(), "class", "named", Some(1)),
ExportSymbol::new(
"PermissionRequiredMixin".to_string(),
"class",
"named",
Some(10),
),
ExportSymbol::new(
"UserPassesTestMixin".to_string(),
"class",
"named",
Some(20),
),
ExportSymbol::new("AuthHelper".to_string(), "class", "named", Some(30)),
],
..Default::default()
};
let analyses = vec![mixin_file];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
let mixin_names: Vec<_> = result
.iter()
.filter(|d| d.symbol.ends_with("Mixin"))
.collect();
assert!(
mixin_names.is_empty(),
"Classes ending in 'Mixin' should not be flagged as dead (Django/Wagtail pattern). Found: {:?}",
mixin_names
);
let has_non_mixin = result.iter().any(|d| d.symbol == "AuthHelper");
assert!(
has_non_mixin,
"Non-mixin classes like 'AuthHelper' should still be flagged when unused"
);
}
#[test]
fn test_weakmap_registry_skips_dead_exports() {
let weakmap_file = FileAnalysis {
path: "src/devtools.ts".to_string(),
language: "ts".to_string(),
has_weak_collections: true, exports: vec![
ExportSymbol::new(
"registerComponent".to_string(),
"function",
"named",
Some(10),
),
ExportSymbol::new(
"getComponentData".to_string(),
"function",
"named",
Some(20),
),
],
..Default::default()
};
let analyses = vec![weakmap_file];
let result = find_dead_exports(&analyses, false, None, DeadFilterConfig::default());
assert!(
result.is_empty(),
"Exports in files with WeakMap/WeakSet should NOT be flagged as dead. Found: {:?}",
result
);
}
#[test]
fn test_paths_match_exact() {
assert!(paths_match("src/App.tsx", "src/App.tsx"));
assert!(paths_match("foo.ts", "foo.ts"));
}
#[test]
fn test_paths_match_with_separators() {
assert!(paths_match("src/App.tsx", "src\\App.tsx"));
assert!(paths_match(
"src\\components\\Button.tsx",
"src/components/Button.tsx"
));
}
#[test]
fn test_paths_match_normalizes_index_and_extension() {
assert!(paths_match("src/utils/index.ts", "./utils"));
assert!(paths_match("src/components/Foo.tsx", "src/components/Foo"));
assert!(paths_match("components/Foo.tsx", "components/Foo.jsx"));
}
#[test]
fn test_paths_match_suffix() {
assert!(paths_match("src/App.tsx", "App.tsx"));
assert!(paths_match("src/components/Button.tsx", "Button.tsx"));
assert!(paths_match("Button.tsx", "src/components/Button.tsx"));
}
#[test]
fn test_paths_match_no_false_positives() {
assert!(!paths_match("foo.ts", "foo.test.ts"));
assert!(!paths_match("Button.tsx", "Button.test.tsx"));
assert!(!paths_match("utils.ts", "utils.spec.ts"));
assert!(!paths_match("App.tsx", "src/MyApp.tsx"));
assert!(!paths_match("Button.tsx", "src/BigButton.tsx"));
}
#[test]
fn test_python_stdlib_exports_not_dead() {
let calendar_module = FileAnalysis {
path: "Lib/calendar.py".to_string(),
language: "py".to_string(),
exports: vec![
ExportSymbol::new("APRIL".to_string(), "__all__", "named", Some(1)),
ExportSymbol::new("APRIL".to_string(), "const", "named", Some(10)),
],
..Default::default()
};
let csv_module = FileAnalysis {
path: "Lib/csv.py".to_string(),
language: "py".to_string(),
exports: vec![
ExportSymbol::new("DictWriter".to_string(), "__all__", "named", Some(1)),
ExportSymbol::new("DictWriter".to_string(), "class", "named", Some(50)),
],
..Default::default()
};
let typing_module = FileAnalysis {
path: "Lib/typing.py".to_string(),
language: "py".to_string(),
exports: vec![
ExportSymbol::new("override".to_string(), "__all__", "named", Some(1)),
ExportSymbol::new("override".to_string(), "function", "named", Some(200)),
],
..Default::default()
};
let analyses = vec![calendar_module, csv_module, typing_module];
let dead_exports = find_dead_exports(
&analyses,
false,
None,
DeadFilterConfig {
include_tests: false,
include_helpers: false,
library_mode: false,
example_globs: Vec::new(),
python_library_mode: true, include_ambient: false,
include_dynamic: false,
dead_ok_globs: Vec::new(),
},
);
assert!(
dead_exports.is_empty(),
"CPython stdlib exports in __all__ should NOT be marked as dead. Found: {:?}",
dead_exports
);
}
#[test]
fn test_python_stdlib_uppercase_constants_not_dead() {
let module = FileAnalysis {
path: "Lib/socket.py".to_string(),
language: "py".to_string(),
exports: vec![
ExportSymbol::new("AF_INET".to_string(), "const", "named", Some(10)),
ExportSymbol::new("SOCK_STREAM".to_string(), "const", "named", Some(20)),
],
..Default::default()
};
let analyses = vec![module];
let dead_exports = find_dead_exports(
&analyses,
false,
None,
DeadFilterConfig {
include_tests: false,
include_helpers: false,
library_mode: false,
example_globs: Vec::new(),
python_library_mode: true,
include_ambient: false,
include_dynamic: false,
dead_ok_globs: Vec::new(),
},
);
assert!(
dead_exports.is_empty(),
"CPython stdlib UPPER_CASE constants should NOT be dead. Found: {:?}",
dead_exports
);
}
#[test]
fn test_shadow_export_detection() {
use crate::types::{ImportEntry, ImportKind, ImportSymbol};
let old_store = FileAnalysis {
path: "stores/conversationHostStore.ts".to_string(),
language: "ts".to_string(),
loc: 361,
exports: vec![ExportSymbol::new(
"conversationHostStore".to_string(),
"const",
"named",
Some(42),
)],
..Default::default()
};
let new_slice = FileAnalysis {
path: "aiStore/slices/conversationHostSlice.ts".to_string(),
language: "ts".to_string(),
loc: 120,
exports: vec![ExportSymbol::new(
"conversationHostStore".to_string(),
"const",
"named",
Some(15),
)],
..Default::default()
};
let mut importer = FileAnalysis {
path: "components/Chat.tsx".to_string(),
language: "tsx".to_string(),
..Default::default()
};
let mut imp = ImportEntry::new(
"aiStore/slices/conversationHostSlice".to_string(),
ImportKind::Static,
);
imp.resolved_path = Some("aiStore/slices/conversationHostSlice.ts".to_string());
imp.symbols.push(ImportSymbol {
name: "conversationHostStore".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let analyses = vec![old_store, new_slice, importer];
let shadows = find_shadow_exports(&analyses);
assert_eq!(shadows.len(), 1, "Should find exactly one shadow export");
let shadow = &shadows[0];
assert_eq!(shadow.symbol, "conversationHostStore");
assert_eq!(
shadow.used_file, "aiStore/slices/conversationHostSlice.ts",
"New file should be marked as USED"
);
assert_eq!(shadow.dead_files.len(), 1, "Should have one dead file");
assert_eq!(
shadow.dead_files[0].file, "stores/conversationHostStore.ts",
"Old file should be marked as DEAD"
);
assert_eq!(
shadow.dead_files[0].loc, 361,
"Should track LOC of dead file"
);
assert_eq!(shadow.total_dead_loc, 361);
}
#[test]
fn test_python_non_stdlib_requires_all() {
let user_module = FileAnalysis {
path: "myapp/utils.py".to_string(), language: "py".to_string(),
exports: vec![ExportSymbol::new(
"helper".to_string(),
"function",
"named",
Some(10),
)],
..Default::default()
};
let analyses = vec![user_module];
let dead_exports = find_dead_exports(
&analyses,
false,
None,
DeadFilterConfig {
include_tests: false,
include_helpers: false,
library_mode: false,
example_globs: Vec::new(),
python_library_mode: true,
include_ambient: false,
include_dynamic: false,
dead_ok_globs: Vec::new(),
},
);
assert_eq!(
dead_exports.len(),
1,
"Non-stdlib exports without __all__ should be marked as dead"
);
assert_eq!(dead_exports[0].symbol, "helper");
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use crate::types::{
ExportSymbol, ImportEntry, ImportKind, ImportSymbol, ReexportEntry, ReexportKind,
};
#[test]
fn test_recommendations_pdf_not_dead() {
let mut importer = FileAnalysis {
path: "src/services/recommendationsExportService.ts".to_string(),
..Default::default()
};
let mut imp = ImportEntry::new(
"../components/pdf/RecommendationsPDFTemplate".to_string(),
ImportKind::Static,
);
imp.resolved_path = Some("src/components/pdf/RecommendationsPDFTemplate.tsx".to_string());
imp.symbols.push(ImportSymbol {
name: "RecommendationsPDFTemplate".to_string(),
alias: None,
is_default: false,
});
importer.imports.push(imp);
let exporter = FileAnalysis {
path: "src/components/pdf/RecommendationsPDFTemplate.tsx".to_string(),
exports: vec![ExportSymbol {
name: "RecommendationsPDFTemplate".to_string(),
kind: "function".to_string(),
export_type: "named".to_string(),
line: Some(25),
params: Vec::new(),
}],
..Default::default()
};
let result = find_dead_exports(
&[importer, exporter],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"RecommendationsPDFTemplate should NOT be dead. Found: {:?}",
result
);
}
#[test]
fn test_dts_reexport_marks_implementation_as_used() {
let mut implementation = FileAnalysis {
path: "packages/svelte/src/easing/index.js".to_string(),
language: "js".to_string(),
..Default::default()
};
implementation.exports = vec![
ExportSymbol::new("linear".to_string(), "function", "named", Some(1)),
ExportSymbol::new("backIn".to_string(), "function", "named", Some(5)),
ExportSymbol::new("backOut".to_string(), "function", "named", Some(10)),
];
let mut declaration = FileAnalysis {
path: "packages/svelte/src/easing/index.d.ts".to_string(),
language: "ts".to_string(),
..Default::default()
};
declaration.reexports.push(ReexportEntry {
source: "./index.js".to_string(),
kind: ReexportKind::Named(vec![
("linear".to_string(), "linear".to_string()),
("backIn".to_string(), "backIn".to_string()),
("backOut".to_string(), "backOut".to_string()),
]),
resolved: Some("packages/svelte/src/easing/index.js".to_string()),
});
let result = find_dead_exports(
&[implementation, declaration],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"Exports re-exported by .d.ts should NOT be marked as dead. Found dead: {:?}",
result
);
}
#[test]
fn test_dts_star_reexport_marks_all_as_used() {
let mut implementation = FileAnalysis {
path: "lib/impl.js".to_string(),
language: "js".to_string(),
..Default::default()
};
implementation.exports = vec![
ExportSymbol::new("funcA".to_string(), "function", "named", Some(1)),
ExportSymbol::new("funcB".to_string(), "function", "named", Some(5)),
ExportSymbol::new("funcC".to_string(), "function", "named", Some(10)),
];
let mut declaration = FileAnalysis {
path: "lib/index.d.ts".to_string(),
language: "ts".to_string(),
..Default::default()
};
declaration.reexports.push(ReexportEntry {
source: "./impl.js".to_string(),
kind: ReexportKind::Star,
resolved: Some("lib/impl.js".to_string()),
});
let result = find_dead_exports(
&[implementation, declaration],
false,
None,
DeadFilterConfig::default(),
);
assert!(
result.is_empty(),
"Exports re-exported via star by .d.ts should NOT be marked as dead. Found dead: {:?}",
result
);
}
}