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