Skip to main content

fallow_core/analyze/
mod.rs

1mod boundary;
2mod package_json_utils;
3mod predicates;
4mod unused_deps;
5mod unused_exports;
6mod unused_files;
7mod unused_members;
8
9use rustc_hash::FxHashMap;
10
11use fallow_config::{PackageJson, ResolvedConfig, Severity};
12
13use crate::discover::FileId;
14use crate::extract::ModuleInfo;
15use crate::graph::ModuleGraph;
16use crate::resolve::ResolvedModule;
17use crate::results::{AnalysisResults, CircularDependency};
18use crate::suppress::{self, IssueKind, Suppression};
19
20use unused_deps::{
21    find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
22    find_unresolved_imports, find_unused_dependencies,
23};
24use unused_exports::{collect_export_usages, find_duplicate_exports, find_unused_exports};
25use unused_files::find_unused_files;
26use unused_members::find_unused_members;
27
28/// Pre-computed line offset tables indexed by `FileId`, built during parse and
29/// carried through the cache. Eliminates redundant file reads during analysis.
30pub(crate) type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
31
32/// Convert a byte offset to (line, col) using pre-computed line offsets.
33/// Falls back to `(1, byte_offset)` when no line table is available.
34pub(crate) fn byte_offset_to_line_col(
35    line_offsets_map: &LineOffsetsMap<'_>,
36    file_id: FileId,
37    byte_offset: u32,
38) -> (u32, u32) {
39    line_offsets_map
40        .get(&file_id)
41        .map_or((1, byte_offset), |offsets| {
42            fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
43        })
44}
45
46/// Read source content from disk, returning empty string on failure.
47/// Only used for LSP Code Lens reference resolution where the referencing
48/// file may not be in the line offsets map.
49fn read_source(path: &std::path::Path) -> String {
50    std::fs::read_to_string(path).unwrap_or_default()
51}
52
53/// Find all dead code in the project.
54#[must_use]
55pub fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
56    find_dead_code_with_resolved(graph, config, &[], None)
57}
58
59/// Find all dead code, with optional resolved module data and plugin context.
60#[must_use]
61pub fn find_dead_code_with_resolved(
62    graph: &ModuleGraph,
63    config: &ResolvedConfig,
64    resolved_modules: &[ResolvedModule],
65    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
66) -> AnalysisResults {
67    find_dead_code_full(
68        graph,
69        config,
70        resolved_modules,
71        plugin_result,
72        &[],
73        &[],
74        false,
75    )
76}
77
78/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
79pub fn find_dead_code_full(
80    graph: &ModuleGraph,
81    config: &ResolvedConfig,
82    resolved_modules: &[ResolvedModule],
83    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
84    workspaces: &[fallow_config::WorkspaceInfo],
85    modules: &[ModuleInfo],
86    collect_usages: bool,
87) -> AnalysisResults {
88    let _span = tracing::info_span!("find_dead_code").entered();
89
90    // Build suppression index: FileId -> suppressions
91    let suppressions_by_file: FxHashMap<FileId, &[Suppression]> = modules
92        .iter()
93        .filter(|m| !m.suppressions.is_empty())
94        .map(|m| (m.file_id, m.suppressions.as_slice()))
95        .collect();
96
97    // Build line offset index: FileId -> pre-computed line start offsets.
98    // Eliminates redundant file reads for byte-to-line/col conversion.
99    let line_offsets_by_file: LineOffsetsMap<'_> = modules
100        .iter()
101        .filter(|m| !m.line_offsets.is_empty())
102        .map(|m| (m.file_id, m.line_offsets.as_slice()))
103        .collect();
104
105    let mut results = AnalysisResults::default();
106
107    if config.rules.unused_files != Severity::Off {
108        results.unused_files = find_unused_files(graph, &suppressions_by_file);
109    }
110
111    if config.rules.unused_exports != Severity::Off || config.rules.unused_types != Severity::Off {
112        let (exports, types) = find_unused_exports(
113            graph,
114            config,
115            plugin_result,
116            &suppressions_by_file,
117            &line_offsets_by_file,
118        );
119        if config.rules.unused_exports != Severity::Off {
120            results.unused_exports = exports;
121        }
122        if config.rules.unused_types != Severity::Off {
123            results.unused_types = types;
124        }
125    }
126
127    if config.rules.unused_enum_members != Severity::Off
128        || config.rules.unused_class_members != Severity::Off
129    {
130        let (enum_members, class_members) = find_unused_members(
131            graph,
132            resolved_modules,
133            &suppressions_by_file,
134            &line_offsets_by_file,
135        );
136        if config.rules.unused_enum_members != Severity::Off {
137            results.unused_enum_members = enum_members;
138        }
139        if config.rules.unused_class_members != Severity::Off {
140            results.unused_class_members = class_members;
141        }
142    }
143
144    // Build merged dependency set from root + all workspace package.json files
145    let pkg_path = config.root.join("package.json");
146    let pkg = PackageJson::load(&pkg_path).ok();
147    if let Some(ref pkg) = pkg {
148        if config.rules.unused_dependencies != Severity::Off
149            || config.rules.unused_dev_dependencies != Severity::Off
150            || config.rules.unused_optional_dependencies != Severity::Off
151        {
152            let (deps, dev_deps, optional_deps) =
153                find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
154            if config.rules.unused_dependencies != Severity::Off {
155                results.unused_dependencies = deps;
156            }
157            if config.rules.unused_dev_dependencies != Severity::Off {
158                results.unused_dev_dependencies = dev_deps;
159            }
160            if config.rules.unused_optional_dependencies != Severity::Off {
161                results.unused_optional_dependencies = optional_deps;
162            }
163        }
164
165        if config.rules.unlisted_dependencies != Severity::Off {
166            results.unlisted_dependencies = find_unlisted_dependencies(
167                graph,
168                pkg,
169                config,
170                workspaces,
171                plugin_result,
172                resolved_modules,
173                &line_offsets_by_file,
174            );
175        }
176    }
177
178    if config.rules.unresolved_imports != Severity::Off && !resolved_modules.is_empty() {
179        let virtual_prefixes: Vec<&str> = plugin_result
180            .map(|pr| {
181                pr.virtual_module_prefixes
182                    .iter()
183                    .map(String::as_str)
184                    .collect()
185            })
186            .unwrap_or_default();
187        let generated_patterns: Vec<&str> = plugin_result
188            .map(|pr| {
189                pr.generated_import_patterns
190                    .iter()
191                    .map(String::as_str)
192                    .collect()
193            })
194            .unwrap_or_default();
195        results.unresolved_imports = find_unresolved_imports(
196            resolved_modules,
197            config,
198            &suppressions_by_file,
199            &virtual_prefixes,
200            &generated_patterns,
201            &line_offsets_by_file,
202        );
203    }
204
205    if config.rules.duplicate_exports != Severity::Off {
206        results.duplicate_exports =
207            find_duplicate_exports(graph, &suppressions_by_file, &line_offsets_by_file);
208    }
209
210    // In production mode, detect dependencies that are only used via type-only imports
211    if config.production
212        && let Some(ref pkg) = pkg
213    {
214        results.type_only_dependencies =
215            find_type_only_dependencies(graph, pkg, config, workspaces);
216    }
217
218    // In non-production mode, detect production deps only imported by test/dev files
219    if !config.production
220        && config.rules.test_only_dependencies != Severity::Off
221        && let Some(ref pkg) = pkg
222    {
223        results.test_only_dependencies =
224            find_test_only_dependencies(graph, pkg, config, workspaces);
225    }
226
227    // Detect architecture boundary violations
228    if config.rules.boundary_violation != Severity::Off && !config.boundaries.is_empty() {
229        results.boundary_violations = boundary::find_boundary_violations(
230            graph,
231            config,
232            &suppressions_by_file,
233            &line_offsets_by_file,
234        );
235    }
236
237    // Detect circular dependencies
238    if config.rules.circular_dependencies != Severity::Off {
239        let cycles = graph.find_cycles();
240        results.circular_dependencies = cycles
241            .into_iter()
242            .filter(|cycle| {
243                // Skip cycles where any participating file has a file-level suppression
244                !cycle.iter().any(|&id| {
245                    suppressions_by_file.get(&id).is_some_and(|supps| {
246                        suppress::is_file_suppressed(supps, IssueKind::CircularDependency)
247                    })
248                })
249            })
250            .map(|cycle| {
251                let files: Vec<std::path::PathBuf> = cycle
252                    .iter()
253                    .map(|&id| graph.modules[id.0 as usize].path.clone())
254                    .collect();
255                let length = files.len();
256                // Look up the import span from cycle[0] → cycle[1] for precise location
257                let (line, col) = if cycle.len() >= 2 {
258                    graph
259                        .find_import_span_start(cycle[0], cycle[1])
260                        .map_or((1, 0), |span_start| {
261                            byte_offset_to_line_col(&line_offsets_by_file, cycle[0], span_start)
262                        })
263                } else {
264                    (1, 0)
265                };
266                CircularDependency {
267                    files,
268                    length,
269                    line,
270                    col,
271                }
272            })
273            .collect();
274    }
275
276    // Collect export usage counts for Code Lens (LSP feature).
277    // Skipped in CLI mode since the field is #[serde(skip)] in all output formats.
278    if collect_usages {
279        results.export_usages = collect_export_usages(graph, &line_offsets_by_file);
280    }
281
282    results
283}
284
285#[cfg(test)]
286mod tests {
287    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
288
289    // Helper: compute line offsets from source and convert byte offset
290    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
291        let offsets = compute_line_offsets(source);
292        byte_offset_to_line_col(&offsets, byte_offset)
293    }
294
295    // ── compute_line_offsets ─────────────────────────────────────
296
297    #[test]
298    fn compute_offsets_empty() {
299        assert_eq!(compute_line_offsets(""), vec![0]);
300    }
301
302    #[test]
303    fn compute_offsets_single_line() {
304        assert_eq!(compute_line_offsets("hello"), vec![0]);
305    }
306
307    #[test]
308    fn compute_offsets_multiline() {
309        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
310    }
311
312    #[test]
313    fn compute_offsets_trailing_newline() {
314        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
315    }
316
317    #[test]
318    fn compute_offsets_crlf() {
319        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
320    }
321
322    #[test]
323    fn compute_offsets_consecutive_newlines() {
324        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
325    }
326
327    // ── byte_offset_to_line_col ─────────────────────────────────
328
329    #[test]
330    fn byte_offset_empty_source() {
331        assert_eq!(line_col("", 0), (1, 0));
332    }
333
334    #[test]
335    fn byte_offset_single_line_start() {
336        assert_eq!(line_col("hello", 0), (1, 0));
337    }
338
339    #[test]
340    fn byte_offset_single_line_middle() {
341        assert_eq!(line_col("hello", 4), (1, 4));
342    }
343
344    #[test]
345    fn byte_offset_multiline_start_of_line2() {
346        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
347    }
348
349    #[test]
350    fn byte_offset_multiline_middle_of_line3() {
351        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
352    }
353
354    #[test]
355    fn byte_offset_at_newline_boundary() {
356        assert_eq!(line_col("line1\nline2", 5), (1, 5));
357    }
358
359    #[test]
360    fn byte_offset_multibyte_utf8() {
361        let source = "hi\n\u{1F600}x";
362        assert_eq!(line_col(source, 3), (2, 0));
363        assert_eq!(line_col(source, 7), (2, 4));
364    }
365
366    #[test]
367    fn byte_offset_multibyte_accented_chars() {
368        let source = "caf\u{00E9}\nbar";
369        assert_eq!(line_col(source, 6), (2, 0));
370        assert_eq!(line_col(source, 3), (1, 3));
371    }
372
373    #[test]
374    fn byte_offset_via_map_fallback() {
375        use super::*;
376        let map: LineOffsetsMap<'_> = FxHashMap::default();
377        assert_eq!(
378            super::byte_offset_to_line_col(&map, FileId(99), 42),
379            (1, 42)
380        );
381    }
382
383    #[test]
384    fn byte_offset_via_map_lookup() {
385        use super::*;
386        let offsets = compute_line_offsets("abc\ndef\nghi");
387        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
388        map.insert(FileId(0), &offsets);
389        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
390    }
391
392    // ── find_dead_code orchestration ──────────────────────────────
393
394    mod orchestration {
395        use super::super::*;
396        use fallow_config::{
397            BoundaryConfig, DuplicatesConfig, FallowConfig, HealthConfig, OutputFormat,
398            RulesConfig, Severity,
399        };
400        use std::path::PathBuf;
401
402        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
403            FallowConfig {
404                schema: None,
405                extends: vec![],
406                entry: vec![],
407                ignore_patterns: vec![],
408                framework: vec![],
409                workspaces: None,
410                ignore_dependencies: vec![],
411                ignore_exports: vec![],
412                duplicates: DuplicatesConfig::default(),
413                health: HealthConfig::default(),
414                rules,
415                boundaries: BoundaryConfig::default(),
416                production: false,
417                plugins: vec![],
418                overrides: vec![],
419                regression: None,
420            }
421            .resolve(
422                PathBuf::from("/tmp/orchestration-test"),
423                OutputFormat::Human,
424                1,
425                true,
426                true,
427            )
428        }
429
430        #[test]
431        fn find_dead_code_all_rules_off_returns_empty() {
432            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
433            use crate::graph::ModuleGraph;
434            use crate::resolve::ResolvedModule;
435            use rustc_hash::FxHashSet;
436
437            let files = vec![DiscoveredFile {
438                id: FileId(0),
439                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
440                size_bytes: 100,
441            }];
442            let entry_points = vec![EntryPoint {
443                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
444                source: EntryPointSource::ManualEntry,
445            }];
446            let resolved = vec![ResolvedModule {
447                file_id: FileId(0),
448                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
449                exports: vec![],
450                re_exports: vec![],
451                resolved_imports: vec![],
452                resolved_dynamic_imports: vec![],
453                resolved_dynamic_patterns: vec![],
454                member_accesses: vec![],
455                whole_object_uses: vec![],
456                has_cjs_exports: false,
457                unused_import_bindings: FxHashSet::default(),
458            }];
459            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
460
461            let rules = RulesConfig {
462                unused_files: Severity::Off,
463                unused_exports: Severity::Off,
464                unused_types: Severity::Off,
465                unused_dependencies: Severity::Off,
466                unused_dev_dependencies: Severity::Off,
467                unused_optional_dependencies: Severity::Off,
468                unused_enum_members: Severity::Off,
469                unused_class_members: Severity::Off,
470                unresolved_imports: Severity::Off,
471                unlisted_dependencies: Severity::Off,
472                duplicate_exports: Severity::Off,
473                type_only_dependencies: Severity::Off,
474                circular_dependencies: Severity::Off,
475                test_only_dependencies: Severity::Off,
476                boundary_violation: Severity::Off,
477            };
478            let config = make_config_with_rules(rules);
479            let results = find_dead_code(&graph, &config);
480
481            assert!(results.unused_files.is_empty());
482            assert!(results.unused_exports.is_empty());
483            assert!(results.unused_types.is_empty());
484            assert!(results.unused_dependencies.is_empty());
485            assert!(results.unused_dev_dependencies.is_empty());
486            assert!(results.unused_optional_dependencies.is_empty());
487            assert!(results.unused_enum_members.is_empty());
488            assert!(results.unused_class_members.is_empty());
489            assert!(results.unresolved_imports.is_empty());
490            assert!(results.unlisted_dependencies.is_empty());
491            assert!(results.duplicate_exports.is_empty());
492            assert!(results.circular_dependencies.is_empty());
493            assert!(results.export_usages.is_empty());
494        }
495
496        #[test]
497        fn find_dead_code_full_collect_usages_flag() {
498            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
499            use crate::extract::ExportName;
500            use crate::graph::{ExportSymbol, ModuleGraph};
501            use crate::resolve::ResolvedModule;
502            use oxc_span::Span;
503            use rustc_hash::FxHashSet;
504
505            let files = vec![DiscoveredFile {
506                id: FileId(0),
507                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
508                size_bytes: 100,
509            }];
510            let entry_points = vec![EntryPoint {
511                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
512                source: EntryPointSource::ManualEntry,
513            }];
514            let resolved = vec![ResolvedModule {
515                file_id: FileId(0),
516                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
517                exports: vec![],
518                re_exports: vec![],
519                resolved_imports: vec![],
520                resolved_dynamic_imports: vec![],
521                resolved_dynamic_patterns: vec![],
522                member_accesses: vec![],
523                whole_object_uses: vec![],
524                has_cjs_exports: false,
525                unused_import_bindings: FxHashSet::default(),
526            }];
527            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
528            graph.modules[0].exports = vec![ExportSymbol {
529                name: ExportName::Named("myExport".to_string()),
530                is_type_only: false,
531                is_public: false,
532                span: Span::new(10, 30),
533                references: vec![],
534                members: vec![],
535            }];
536
537            let rules = RulesConfig::default();
538            let config = make_config_with_rules(rules);
539
540            // Without collect_usages
541            let results_no_collect = find_dead_code_full(
542                &graph,
543                &config,
544                &[],
545                None,
546                &[],
547                &[],
548                false, // collect_usages = false
549            );
550            assert!(
551                results_no_collect.export_usages.is_empty(),
552                "export_usages should be empty when collect_usages is false"
553            );
554
555            // With collect_usages
556            let results_with_collect = find_dead_code_full(
557                &graph,
558                &config,
559                &[],
560                None,
561                &[],
562                &[],
563                true, // collect_usages = true
564            );
565            assert!(
566                !results_with_collect.export_usages.is_empty(),
567                "export_usages should be populated when collect_usages is true"
568            );
569            assert_eq!(
570                results_with_collect.export_usages[0].export_name,
571                "myExport"
572            );
573        }
574
575        #[test]
576        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
577            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
578            use crate::graph::ModuleGraph;
579            use crate::resolve::ResolvedModule;
580            use rustc_hash::FxHashSet;
581
582            let files = vec![DiscoveredFile {
583                id: FileId(0),
584                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
585                size_bytes: 100,
586            }];
587            let entry_points = vec![EntryPoint {
588                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
589                source: EntryPointSource::ManualEntry,
590            }];
591            let resolved = vec![ResolvedModule {
592                file_id: FileId(0),
593                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
594                exports: vec![],
595                re_exports: vec![],
596                resolved_imports: vec![],
597                resolved_dynamic_imports: vec![],
598                resolved_dynamic_patterns: vec![],
599                member_accesses: vec![],
600                whole_object_uses: vec![],
601                has_cjs_exports: false,
602                unused_import_bindings: FxHashSet::default(),
603            }];
604            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
605            let config = make_config_with_rules(RulesConfig::default());
606
607            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
608            let results = find_dead_code(&graph, &config);
609            // The entry point export analysis is skipped, so these should be empty
610            assert!(results.unused_exports.is_empty());
611        }
612
613        #[test]
614        fn suppressions_built_from_modules() {
615            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
616            use crate::extract::ModuleInfo;
617            use crate::graph::ModuleGraph;
618            use crate::resolve::ResolvedModule;
619            use crate::suppress::{IssueKind, Suppression};
620            use rustc_hash::FxHashSet;
621
622            let files = vec![
623                DiscoveredFile {
624                    id: FileId(0),
625                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
626                    size_bytes: 100,
627                },
628                DiscoveredFile {
629                    id: FileId(1),
630                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
631                    size_bytes: 100,
632                },
633            ];
634            let entry_points = vec![EntryPoint {
635                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
636                source: EntryPointSource::ManualEntry,
637            }];
638            let resolved = files
639                .iter()
640                .map(|f| ResolvedModule {
641                    file_id: f.id,
642                    path: f.path.clone(),
643                    exports: vec![],
644                    re_exports: vec![],
645                    resolved_imports: vec![],
646                    resolved_dynamic_imports: vec![],
647                    resolved_dynamic_patterns: vec![],
648                    member_accesses: vec![],
649                    whole_object_uses: vec![],
650                    has_cjs_exports: false,
651                    unused_import_bindings: FxHashSet::default(),
652                })
653                .collect::<Vec<_>>();
654            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
655
656            // Create module info with a file-level suppression for unused files
657            let modules = vec![ModuleInfo {
658                file_id: FileId(1),
659                exports: vec![],
660                imports: vec![],
661                re_exports: vec![],
662                dynamic_imports: vec![],
663                dynamic_import_patterns: vec![],
664                require_calls: vec![],
665                member_accesses: vec![],
666                whole_object_uses: vec![],
667                has_cjs_exports: false,
668                content_hash: 0,
669                suppressions: vec![Suppression {
670                    line: 0,
671                    kind: Some(IssueKind::UnusedFile),
672                }],
673                unused_import_bindings: vec![],
674                line_offsets: vec![],
675                complexity: vec![],
676            }];
677
678            let rules = RulesConfig {
679                unused_files: Severity::Error,
680                ..RulesConfig::default()
681            };
682            let config = make_config_with_rules(rules);
683
684            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
685
686            // The suppression should prevent utils.ts from being reported as unused
687            // (it would normally be unused since only entry.ts is an entry point).
688            // Note: unused_files also checks if the file exists on disk, so it
689            // may still be filtered out. The key is the suppression path is exercised.
690            assert!(
691                !results
692                    .unused_files
693                    .iter()
694                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
695                "suppressed file should not appear in unused_files"
696            );
697        }
698    }
699}