Skip to main content

fallow_core/analyze/
mod.rs

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