Skip to main content

the_code_graph_domain/analysis/
dead_code.rs

1use crate::analysis::flow::detect_entry_points;
2use crate::model::{
3    DeadCodeAnalysis, DeadCodeConfig, DeadCodeSummary, DeadSymbol, Edge, EdgeKind, EntryPointKind,
4    FlowConfig, SymbolKind, SymbolNode,
5};
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use std::collections::{HashMap, HashSet};
8
9/// Edge kinds that represent actual symbol usage.
10/// Excludes structural edges (Contains, ChildOf, HasDecorator) and TestedBy.
11const USAGE_EDGES: &[EdgeKind] = &[
12    EdgeKind::Calls,
13    EdgeKind::Extends,
14    EdgeKind::Implements,
15    EdgeKind::Embeds,
16    EdgeKind::ImportsFrom,
17    EdgeKind::ReExport,
18    EdgeKind::BarrelReExportAll,
19    EdgeKind::TypeReference,
20    EdgeKind::DotImport,
21    EdgeKind::DependsOn,
22    EdgeKind::ConditionalImport,
23    EdgeKind::SideEffectImport,
24];
25
26/// Build a GlobSet from a list of patterns. Returns None if patterns is empty.
27fn build_glob_set(patterns: &[String]) -> Option<GlobSet> {
28    if patterns.is_empty() {
29        return None;
30    }
31    let mut builder = GlobSetBuilder::new();
32    for pattern in patterns {
33        if let Ok(glob) = Glob::new(pattern) {
34            builder.add(glob);
35        }
36    }
37    builder.build().ok()
38}
39
40/// Detect dead code: symbols with zero incoming usage-edges, after applying
41/// exclusion layers (entry points, exports, tests, migrations, user patterns).
42///
43/// Complexity: O(E + S) — single pass over edges, single pass over symbols.
44pub fn detect_dead_code(
45    symbols: &[SymbolNode],
46    edges: &[Edge],
47    config: &DeadCodeConfig,
48) -> DeadCodeAnalysis {
49    // 1. Build alive set from usage edges
50    let usage_set: HashSet<&EdgeKind> = USAGE_EDGES.iter().collect();
51    let alive: HashSet<&str> = edges
52        .iter()
53        .filter(|e| usage_set.contains(&e.kind))
54        .map(|e| e.target.as_str())
55        .collect();
56
57    // 2. Detect entry points, filter OUT Test entries (layer 3 handles tests)
58    let entry_points = detect_entry_points(symbols, edges, &FlowConfig::default());
59    let mut entry_point_names: HashSet<&str> = entry_points
60        .iter()
61        .filter(|ep| ep.kind != EntryPointKind::Test)
62        .map(|ep| ep.qualified_name.as_str())
63        .collect();
64
65    // 3. Resolve additional entry_point_patterns from config
66    let ep_glob = build_glob_set(&config.entry_point_patterns);
67    if let Some(ref gs) = ep_glob {
68        for sym in symbols {
69            if gs.is_match(&sym.qualified_name) {
70                entry_point_names.insert(&sym.qualified_name);
71            }
72        }
73    }
74
75    // 4. Build migration file glob set
76    let migration_glob = build_glob_set(&config.migration_patterns);
77
78    // 5. Build user exclusion glob set
79    let user_glob = build_glob_set(&config.exclude_patterns);
80
81    // 6. Classify each symbol through exclusion layers
82    let mut dead_symbols = Vec::new();
83    let mut excluded_count = 0usize;
84    let total_symbols = symbols.len();
85
86    for sym in symbols {
87        // Alive check — target of at least one usage edge
88        if alive.contains(sym.qualified_name.as_str()) {
89            continue;
90        }
91
92        // Layer 1: Entry points (Main, HttpHandler, CliCommand, PublicRoot — NOT Test)
93        if entry_point_names.contains(sym.qualified_name.as_str()) {
94            excluded_count += 1;
95            continue;
96        }
97
98        // Layer 2: Exported symbols
99        if sym.is_exported {
100            excluded_count += 1;
101            continue;
102        }
103
104        // Layer 3: Test functions (unless include_tests is set)
105        if sym.is_test && !config.include_tests {
106            excluded_count += 1;
107            continue;
108        }
109
110        // Layer 4: Migration files
111        if let Some(ref gs) = migration_glob {
112            let file_str = sym.location.file.to_string_lossy();
113            if gs.is_match(file_str.as_ref()) {
114                excluded_count += 1;
115                continue;
116            }
117        }
118
119        // Layer 5: User-configured patterns (match on qualified name or file path)
120        if let Some(ref gs) = user_glob {
121            let file_str = sym.location.file.to_string_lossy();
122            if gs.is_match(&sym.qualified_name) || gs.is_match(file_str.as_ref()) {
123                excluded_count += 1;
124                continue;
125            }
126        }
127
128        // Symbol is dead
129        dead_symbols.push(DeadSymbol {
130            qualified_name: sym.qualified_name.clone(),
131            kind: sym.kind,
132            file_path: sym.location.file.to_string_lossy().to_string(),
133            line: sym.location.line_start,
134            visibility: sym.visibility,
135        });
136    }
137
138    // 7. Apply kind_filter (display-layer only — does not affect excluded_count)
139    if let Some(ref kinds) = config.kind_filter {
140        let kind_set: HashSet<&SymbolKind> = kinds.iter().collect();
141        dead_symbols.retain(|s| kind_set.contains(&s.kind));
142    }
143
144    // 8. Build summary
145    let dead_count = dead_symbols.len();
146    let dead_percentage = if total_symbols > 0 {
147        dead_count as f64 / total_symbols as f64 * 100.0
148    } else {
149        0.0
150    };
151
152    let mut dead_by_kind: HashMap<SymbolKind, usize> = HashMap::new();
153    let mut dead_by_file_map: HashMap<String, usize> = HashMap::new();
154    for ds in &dead_symbols {
155        *dead_by_kind.entry(ds.kind).or_default() += 1;
156        *dead_by_file_map.entry(ds.file_path.clone()).or_default() += 1;
157    }
158    let mut dead_by_file: Vec<(String, usize)> = dead_by_file_map.into_iter().collect();
159    dead_by_file.sort_by(|a, b| b.1.cmp(&a.1));
160
161    DeadCodeAnalysis {
162        dead_symbols,
163        summary: DeadCodeSummary {
164            total_symbols,
165            dead_count,
166            dead_percentage,
167            excluded_count,
168            dead_by_kind,
169            dead_by_file,
170        },
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::model::{Edge, EdgeKind, Location, SymbolKind, SymbolNode, Visibility};
178
179    fn make_symbol(name: &str, file: &str) -> SymbolNode {
180        SymbolNode {
181            name: name.split("::").last().unwrap_or(name).into(),
182            qualified_name: name.into(),
183            kind: SymbolKind::Function,
184            location: Location {
185                file: file.into(),
186                line_start: 1,
187                line_end: 10,
188                col_start: 0,
189                col_end: 0,
190            },
191            visibility: Visibility::Public,
192            is_exported: false,
193            is_async: false,
194            is_test: false,
195            decorators: vec![],
196            signature: None,
197        }
198    }
199
200    fn make_edge(source: &str, target: &str, kind: EdgeKind) -> Edge {
201        Edge {
202            kind,
203            source: source.into(),
204            target: target.into(),
205            metadata: None,
206        }
207    }
208
209    #[test]
210    fn unused_symbol_detected() {
211        let symbols = vec![make_symbol("src/lib.rs::unused_fn", "src/lib.rs")];
212        let edges: Vec<Edge> = vec![];
213        let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
214        assert_eq!(result.dead_symbols.len(), 1);
215        assert_eq!(
216            result.dead_symbols[0].qualified_name,
217            "src/lib.rs::unused_fn"
218        );
219    }
220
221    #[test]
222    fn used_symbol_alive() {
223        let symbols = vec![make_symbol("src/lib.rs::used_fn", "src/lib.rs")];
224        let edges = vec![make_edge(
225            "src/main.rs::main",
226            "src/lib.rs::used_fn",
227            EdgeKind::Calls,
228        )];
229        let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
230        assert_eq!(result.dead_symbols.len(), 0);
231    }
232
233    #[test]
234    fn structural_edges_do_not_count_as_usage() {
235        let symbols = vec![make_symbol("src/lib.rs::inner_fn", "src/lib.rs")];
236        let edges = vec![make_edge(
237            "src/lib.rs::Module",
238            "src/lib.rs::inner_fn",
239            EdgeKind::Contains,
240        )];
241        let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
242        assert_eq!(
243            result.dead_symbols.len(),
244            1,
245            "Contains edge should not make symbol alive"
246        );
247    }
248
249    #[test]
250    fn tested_by_does_not_count_as_usage() {
251        let symbols = vec![make_symbol("src/lib.rs::fn_only_tested", "src/lib.rs")];
252        let edges = vec![make_edge(
253            "tests/test.rs::test_fn",
254            "src/lib.rs::fn_only_tested",
255            EdgeKind::TestedBy,
256        )];
257        let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
258        assert_eq!(
259            result.dead_symbols.len(),
260            1,
261            "TestedBy should not make symbol alive"
262        );
263    }
264
265    #[test]
266    fn exported_symbol_excluded() {
267        let mut sym = make_symbol("src/lib.rs::public_api", "src/lib.rs");
268        sym.is_exported = true;
269        let result = detect_dead_code(&[sym], &[], &DeadCodeConfig::default());
270        assert_eq!(result.dead_symbols.len(), 0);
271        assert_eq!(result.summary.excluded_count, 1);
272    }
273
274    #[test]
275    fn test_function_excluded_by_default() {
276        let mut sym = make_symbol("src/lib.rs::test_helper", "src/lib.rs");
277        sym.is_test = true;
278        let result = detect_dead_code(&[sym], &[], &DeadCodeConfig::default());
279        assert_eq!(result.dead_symbols.len(), 0);
280        assert_eq!(result.summary.excluded_count, 1);
281    }
282
283    #[test]
284    fn include_tests_flags_dead_tests() {
285        let mut sym = make_symbol("src/lib.rs::test_helper", "src/lib.rs");
286        sym.is_test = true;
287        let config = DeadCodeConfig {
288            include_tests: true,
289            ..DeadCodeConfig::default()
290        };
291        let result = detect_dead_code(&[sym], &[], &config);
292        assert_eq!(
293            result.dead_symbols.len(),
294            1,
295            "test fn should be flagged when include_tests=true"
296        );
297    }
298
299    #[test]
300    fn migration_file_excluded() {
301        let sym = make_symbol("migrations/001.rs::up", "migrations/001.rs");
302        let result = detect_dead_code(&[sym], &[], &DeadCodeConfig::default());
303        assert_eq!(result.dead_symbols.len(), 0);
304        assert_eq!(result.summary.excluded_count, 1);
305    }
306
307    #[test]
308    fn user_pattern_excludes_by_qualified_name() {
309        let sym = make_symbol(
310            "src/generated/types.rs::AutoStruct",
311            "src/generated/types.rs",
312        );
313        let config = DeadCodeConfig {
314            exclude_patterns: vec!["**/generated/**".into()],
315            ..DeadCodeConfig::default()
316        };
317        let result = detect_dead_code(&[sym], &[], &config);
318        assert_eq!(result.dead_symbols.len(), 0);
319        assert_eq!(result.summary.excluded_count, 1);
320    }
321
322    #[test]
323    fn kind_filter_restricts_results() {
324        let mut sym_fn = make_symbol("src/lib.rs::dead_fn", "src/lib.rs");
325        sym_fn.kind = SymbolKind::Function;
326        let mut sym_struct = make_symbol("src/lib.rs::DeadStruct", "src/lib.rs");
327        sym_struct.kind = SymbolKind::Struct;
328        let config = DeadCodeConfig {
329            kind_filter: Some(vec![SymbolKind::Function]),
330            ..DeadCodeConfig::default()
331        };
332        let result = detect_dead_code(&[sym_fn, sym_struct], &[], &config);
333        assert_eq!(result.dead_symbols.len(), 1);
334        assert_eq!(result.dead_symbols[0].kind, SymbolKind::Function);
335    }
336
337    #[test]
338    fn entry_point_test_kind_not_excluded_as_entry_point() {
339        let mut sym = make_symbol("src/lib.rs::test_main", "src/lib.rs");
340        sym.is_test = true;
341        sym.kind = SymbolKind::Test;
342        let config = DeadCodeConfig {
343            include_tests: true,
344            ..DeadCodeConfig::default()
345        };
346        let result = detect_dead_code(&[sym], &[], &config);
347        assert_eq!(
348            result.dead_symbols.len(),
349            1,
350            "Test entry points should not be excluded via entry point layer"
351        );
352    }
353
354    #[test]
355    fn entry_point_patterns_add_exclusions() {
356        let sym = make_symbol("src/api.rs::handle_request", "src/api.rs");
357        let config = DeadCodeConfig {
358            entry_point_patterns: vec!["**::handle_*".into()],
359            ..DeadCodeConfig::default()
360        };
361        let result = detect_dead_code(&[sym], &[], &config);
362        assert_eq!(result.dead_symbols.len(), 0);
363        assert_eq!(result.summary.excluded_count, 1);
364    }
365
366    #[test]
367    fn summary_statistics_correct() {
368        let syms = vec![
369            make_symbol("src/a.rs::dead1", "src/a.rs"),
370            make_symbol("src/a.rs::dead2", "src/a.rs"),
371            make_symbol("src/b.rs::dead3", "src/b.rs"),
372        ];
373        let result = detect_dead_code(&syms, &[], &DeadCodeConfig::default());
374        assert_eq!(result.summary.total_symbols, 3);
375        assert_eq!(result.summary.dead_count, 3);
376        assert!((result.summary.dead_percentage - 100.0).abs() < f64::EPSILON);
377        assert_eq!(result.summary.dead_by_kind[&SymbolKind::Function], 3);
378        // dead_by_file sorted by count desc
379        assert_eq!(result.summary.dead_by_file[0], ("src/a.rs".to_string(), 2));
380        assert_eq!(result.summary.dead_by_file[1], ("src/b.rs".to_string(), 1));
381    }
382
383    #[test]
384    fn empty_graph_returns_zero_percentage() {
385        let result = detect_dead_code(&[], &[], &DeadCodeConfig::default());
386        assert_eq!(result.summary.total_symbols, 0);
387        assert_eq!(result.summary.dead_count, 0);
388        assert!((result.summary.dead_percentage - 0.0).abs() < f64::EPSILON);
389    }
390
391    #[test]
392    fn exclusion_layer_order_first_match_wins() {
393        let mut sym = make_symbol("src/lib.rs::exported_test", "src/lib.rs");
394        sym.is_exported = true;
395        sym.is_test = true;
396        let config = DeadCodeConfig {
397            include_tests: true,
398            ..DeadCodeConfig::default()
399        };
400        let result = detect_dead_code(&[sym], &[], &config);
401        assert_eq!(
402            result.dead_symbols.len(),
403            0,
404            "exported symbol excluded regardless of include_tests"
405        );
406        assert_eq!(result.summary.excluded_count, 1);
407    }
408}