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    // Sort all result arrays for deterministic output ordering.
283    // Parallel collection and FxHashMap iteration don't guarantee order,
284    // so without sorting the same project can produce different orderings.
285    results.sort();
286
287    results
288}
289
290#[cfg(test)]
291mod tests {
292    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
293
294    // Helper: compute line offsets from source and convert byte offset
295    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
296        let offsets = compute_line_offsets(source);
297        byte_offset_to_line_col(&offsets, byte_offset)
298    }
299
300    // ── compute_line_offsets ─────────────────────────────────────
301
302    #[test]
303    fn compute_offsets_empty() {
304        assert_eq!(compute_line_offsets(""), vec![0]);
305    }
306
307    #[test]
308    fn compute_offsets_single_line() {
309        assert_eq!(compute_line_offsets("hello"), vec![0]);
310    }
311
312    #[test]
313    fn compute_offsets_multiline() {
314        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
315    }
316
317    #[test]
318    fn compute_offsets_trailing_newline() {
319        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
320    }
321
322    #[test]
323    fn compute_offsets_crlf() {
324        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
325    }
326
327    #[test]
328    fn compute_offsets_consecutive_newlines() {
329        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
330    }
331
332    // ── byte_offset_to_line_col ─────────────────────────────────
333
334    #[test]
335    fn byte_offset_empty_source() {
336        assert_eq!(line_col("", 0), (1, 0));
337    }
338
339    #[test]
340    fn byte_offset_single_line_start() {
341        assert_eq!(line_col("hello", 0), (1, 0));
342    }
343
344    #[test]
345    fn byte_offset_single_line_middle() {
346        assert_eq!(line_col("hello", 4), (1, 4));
347    }
348
349    #[test]
350    fn byte_offset_multiline_start_of_line2() {
351        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
352    }
353
354    #[test]
355    fn byte_offset_multiline_middle_of_line3() {
356        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
357    }
358
359    #[test]
360    fn byte_offset_at_newline_boundary() {
361        assert_eq!(line_col("line1\nline2", 5), (1, 5));
362    }
363
364    #[test]
365    fn byte_offset_multibyte_utf8() {
366        let source = "hi\n\u{1F600}x";
367        assert_eq!(line_col(source, 3), (2, 0));
368        assert_eq!(line_col(source, 7), (2, 4));
369    }
370
371    #[test]
372    fn byte_offset_multibyte_accented_chars() {
373        let source = "caf\u{00E9}\nbar";
374        assert_eq!(line_col(source, 6), (2, 0));
375        assert_eq!(line_col(source, 3), (1, 3));
376    }
377
378    #[test]
379    fn byte_offset_via_map_fallback() {
380        use super::*;
381        let map: LineOffsetsMap<'_> = FxHashMap::default();
382        assert_eq!(
383            super::byte_offset_to_line_col(&map, FileId(99), 42),
384            (1, 42)
385        );
386    }
387
388    #[test]
389    fn byte_offset_via_map_lookup() {
390        use super::*;
391        let offsets = compute_line_offsets("abc\ndef\nghi");
392        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
393        map.insert(FileId(0), &offsets);
394        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
395    }
396
397    // ── find_dead_code orchestration ──────────────────────────────
398
399    mod orchestration {
400        use super::super::*;
401        use fallow_config::{
402            BoundaryConfig, DuplicatesConfig, FallowConfig, HealthConfig, OutputFormat,
403            RulesConfig, Severity,
404        };
405        use std::path::PathBuf;
406
407        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
408            FallowConfig {
409                schema: None,
410                extends: vec![],
411                entry: vec![],
412                ignore_patterns: vec![],
413                framework: vec![],
414                workspaces: None,
415                ignore_dependencies: vec![],
416                ignore_exports: vec![],
417                duplicates: DuplicatesConfig::default(),
418                health: HealthConfig::default(),
419                rules,
420                boundaries: BoundaryConfig::default(),
421                production: false,
422                plugins: vec![],
423                overrides: vec![],
424                regression: None,
425            }
426            .resolve(
427                PathBuf::from("/tmp/orchestration-test"),
428                OutputFormat::Human,
429                1,
430                true,
431                true,
432            )
433        }
434
435        #[test]
436        fn find_dead_code_all_rules_off_returns_empty() {
437            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
438            use crate::graph::ModuleGraph;
439            use crate::resolve::ResolvedModule;
440            use rustc_hash::FxHashSet;
441
442            let files = vec![DiscoveredFile {
443                id: FileId(0),
444                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
445                size_bytes: 100,
446            }];
447            let entry_points = vec![EntryPoint {
448                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
449                source: EntryPointSource::ManualEntry,
450            }];
451            let resolved = vec![ResolvedModule {
452                file_id: FileId(0),
453                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
454                exports: vec![],
455                re_exports: vec![],
456                resolved_imports: vec![],
457                resolved_dynamic_imports: vec![],
458                resolved_dynamic_patterns: vec![],
459                member_accesses: vec![],
460                whole_object_uses: vec![],
461                has_cjs_exports: false,
462                unused_import_bindings: FxHashSet::default(),
463            }];
464            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
465
466            let rules = RulesConfig {
467                unused_files: Severity::Off,
468                unused_exports: Severity::Off,
469                unused_types: Severity::Off,
470                unused_dependencies: Severity::Off,
471                unused_dev_dependencies: Severity::Off,
472                unused_optional_dependencies: Severity::Off,
473                unused_enum_members: Severity::Off,
474                unused_class_members: Severity::Off,
475                unresolved_imports: Severity::Off,
476                unlisted_dependencies: Severity::Off,
477                duplicate_exports: Severity::Off,
478                type_only_dependencies: Severity::Off,
479                circular_dependencies: Severity::Off,
480                test_only_dependencies: Severity::Off,
481                boundary_violation: Severity::Off,
482            };
483            let config = make_config_with_rules(rules);
484            let results = find_dead_code(&graph, &config);
485
486            assert!(results.unused_files.is_empty());
487            assert!(results.unused_exports.is_empty());
488            assert!(results.unused_types.is_empty());
489            assert!(results.unused_dependencies.is_empty());
490            assert!(results.unused_dev_dependencies.is_empty());
491            assert!(results.unused_optional_dependencies.is_empty());
492            assert!(results.unused_enum_members.is_empty());
493            assert!(results.unused_class_members.is_empty());
494            assert!(results.unresolved_imports.is_empty());
495            assert!(results.unlisted_dependencies.is_empty());
496            assert!(results.duplicate_exports.is_empty());
497            assert!(results.circular_dependencies.is_empty());
498            assert!(results.export_usages.is_empty());
499        }
500
501        #[test]
502        fn find_dead_code_full_collect_usages_flag() {
503            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
504            use crate::extract::ExportName;
505            use crate::graph::{ExportSymbol, ModuleGraph};
506            use crate::resolve::ResolvedModule;
507            use oxc_span::Span;
508            use rustc_hash::FxHashSet;
509
510            let files = vec![DiscoveredFile {
511                id: FileId(0),
512                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
513                size_bytes: 100,
514            }];
515            let entry_points = vec![EntryPoint {
516                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
517                source: EntryPointSource::ManualEntry,
518            }];
519            let resolved = vec![ResolvedModule {
520                file_id: FileId(0),
521                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
522                exports: vec![],
523                re_exports: vec![],
524                resolved_imports: vec![],
525                resolved_dynamic_imports: vec![],
526                resolved_dynamic_patterns: vec![],
527                member_accesses: vec![],
528                whole_object_uses: vec![],
529                has_cjs_exports: false,
530                unused_import_bindings: FxHashSet::default(),
531            }];
532            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
533            graph.modules[0].exports = vec![ExportSymbol {
534                name: ExportName::Named("myExport".to_string()),
535                is_type_only: false,
536                is_public: false,
537                span: Span::new(10, 30),
538                references: vec![],
539                members: vec![],
540            }];
541
542            let rules = RulesConfig::default();
543            let config = make_config_with_rules(rules);
544
545            // Without collect_usages
546            let results_no_collect = find_dead_code_full(
547                &graph,
548                &config,
549                &[],
550                None,
551                &[],
552                &[],
553                false, // collect_usages = false
554            );
555            assert!(
556                results_no_collect.export_usages.is_empty(),
557                "export_usages should be empty when collect_usages is false"
558            );
559
560            // With collect_usages
561            let results_with_collect = find_dead_code_full(
562                &graph,
563                &config,
564                &[],
565                None,
566                &[],
567                &[],
568                true, // collect_usages = true
569            );
570            assert!(
571                !results_with_collect.export_usages.is_empty(),
572                "export_usages should be populated when collect_usages is true"
573            );
574            assert_eq!(
575                results_with_collect.export_usages[0].export_name,
576                "myExport"
577            );
578        }
579
580        #[test]
581        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
582            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
583            use crate::graph::ModuleGraph;
584            use crate::resolve::ResolvedModule;
585            use rustc_hash::FxHashSet;
586
587            let files = vec![DiscoveredFile {
588                id: FileId(0),
589                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
590                size_bytes: 100,
591            }];
592            let entry_points = vec![EntryPoint {
593                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
594                source: EntryPointSource::ManualEntry,
595            }];
596            let resolved = vec![ResolvedModule {
597                file_id: FileId(0),
598                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
599                exports: vec![],
600                re_exports: vec![],
601                resolved_imports: vec![],
602                resolved_dynamic_imports: vec![],
603                resolved_dynamic_patterns: vec![],
604                member_accesses: vec![],
605                whole_object_uses: vec![],
606                has_cjs_exports: false,
607                unused_import_bindings: FxHashSet::default(),
608            }];
609            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
610            let config = make_config_with_rules(RulesConfig::default());
611
612            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
613            let results = find_dead_code(&graph, &config);
614            // The entry point export analysis is skipped, so these should be empty
615            assert!(results.unused_exports.is_empty());
616        }
617
618        #[test]
619        fn suppressions_built_from_modules() {
620            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
621            use crate::extract::ModuleInfo;
622            use crate::graph::ModuleGraph;
623            use crate::resolve::ResolvedModule;
624            use crate::suppress::{IssueKind, Suppression};
625            use rustc_hash::FxHashSet;
626
627            let files = vec![
628                DiscoveredFile {
629                    id: FileId(0),
630                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
631                    size_bytes: 100,
632                },
633                DiscoveredFile {
634                    id: FileId(1),
635                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
636                    size_bytes: 100,
637                },
638            ];
639            let entry_points = vec![EntryPoint {
640                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
641                source: EntryPointSource::ManualEntry,
642            }];
643            let resolved = files
644                .iter()
645                .map(|f| ResolvedModule {
646                    file_id: f.id,
647                    path: f.path.clone(),
648                    exports: vec![],
649                    re_exports: vec![],
650                    resolved_imports: vec![],
651                    resolved_dynamic_imports: vec![],
652                    resolved_dynamic_patterns: vec![],
653                    member_accesses: vec![],
654                    whole_object_uses: vec![],
655                    has_cjs_exports: false,
656                    unused_import_bindings: FxHashSet::default(),
657                })
658                .collect::<Vec<_>>();
659            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
660
661            // Create module info with a file-level suppression for unused files
662            let modules = vec![ModuleInfo {
663                file_id: FileId(1),
664                exports: vec![],
665                imports: vec![],
666                re_exports: vec![],
667                dynamic_imports: vec![],
668                dynamic_import_patterns: vec![],
669                require_calls: vec![],
670                member_accesses: vec![],
671                whole_object_uses: vec![],
672                has_cjs_exports: false,
673                content_hash: 0,
674                suppressions: vec![Suppression {
675                    line: 0,
676                    kind: Some(IssueKind::UnusedFile),
677                }],
678                unused_import_bindings: vec![],
679                line_offsets: vec![],
680                complexity: vec![],
681            }];
682
683            let rules = RulesConfig {
684                unused_files: Severity::Error,
685                ..RulesConfig::default()
686            };
687            let config = make_config_with_rules(rules);
688
689            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
690
691            // The suppression should prevent utils.ts from being reported as unused
692            // (it would normally be unused since only entry.ts is an entry point).
693            // Note: unused_files also checks if the file exists on disk, so it
694            // may still be filtered out. The key is the suppression path is exercised.
695            assert!(
696                !results
697                    .unused_files
698                    .iter()
699                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
700                "suppressed file should not appear in unused_files"
701            );
702        }
703    }
704}