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            }
396            .resolve(
397                PathBuf::from("/tmp/orchestration-test"),
398                OutputFormat::Human,
399                1,
400                true,
401                true,
402            )
403        }
404
405        #[test]
406        fn find_dead_code_all_rules_off_returns_empty() {
407            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
408            use crate::graph::ModuleGraph;
409            use crate::resolve::ResolvedModule;
410            use rustc_hash::FxHashSet;
411
412            let files = vec![DiscoveredFile {
413                id: FileId(0),
414                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
415                size_bytes: 100,
416            }];
417            let entry_points = vec![EntryPoint {
418                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
419                source: EntryPointSource::ManualEntry,
420            }];
421            let resolved = vec![ResolvedModule {
422                file_id: FileId(0),
423                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
424                exports: vec![],
425                re_exports: vec![],
426                resolved_imports: vec![],
427                resolved_dynamic_imports: vec![],
428                resolved_dynamic_patterns: vec![],
429                member_accesses: vec![],
430                whole_object_uses: vec![],
431                has_cjs_exports: false,
432                unused_import_bindings: FxHashSet::default(),
433            }];
434            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
435
436            let rules = RulesConfig {
437                unused_files: Severity::Off,
438                unused_exports: Severity::Off,
439                unused_types: Severity::Off,
440                unused_dependencies: Severity::Off,
441                unused_dev_dependencies: Severity::Off,
442                unused_optional_dependencies: Severity::Off,
443                unused_enum_members: Severity::Off,
444                unused_class_members: Severity::Off,
445                unresolved_imports: Severity::Off,
446                unlisted_dependencies: Severity::Off,
447                duplicate_exports: Severity::Off,
448                type_only_dependencies: Severity::Off,
449                circular_dependencies: Severity::Off,
450                test_only_dependencies: Severity::Off,
451            };
452            let config = make_config_with_rules(rules);
453            let results = find_dead_code(&graph, &config);
454
455            assert!(results.unused_files.is_empty());
456            assert!(results.unused_exports.is_empty());
457            assert!(results.unused_types.is_empty());
458            assert!(results.unused_dependencies.is_empty());
459            assert!(results.unused_dev_dependencies.is_empty());
460            assert!(results.unused_optional_dependencies.is_empty());
461            assert!(results.unused_enum_members.is_empty());
462            assert!(results.unused_class_members.is_empty());
463            assert!(results.unresolved_imports.is_empty());
464            assert!(results.unlisted_dependencies.is_empty());
465            assert!(results.duplicate_exports.is_empty());
466            assert!(results.circular_dependencies.is_empty());
467            assert!(results.export_usages.is_empty());
468        }
469
470        #[test]
471        fn find_dead_code_full_collect_usages_flag() {
472            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
473            use crate::extract::ExportName;
474            use crate::graph::{ExportSymbol, ModuleGraph};
475            use crate::resolve::ResolvedModule;
476            use oxc_span::Span;
477            use rustc_hash::FxHashSet;
478
479            let files = vec![DiscoveredFile {
480                id: FileId(0),
481                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
482                size_bytes: 100,
483            }];
484            let entry_points = vec![EntryPoint {
485                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
486                source: EntryPointSource::ManualEntry,
487            }];
488            let resolved = vec![ResolvedModule {
489                file_id: FileId(0),
490                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
491                exports: vec![],
492                re_exports: vec![],
493                resolved_imports: vec![],
494                resolved_dynamic_imports: vec![],
495                resolved_dynamic_patterns: vec![],
496                member_accesses: vec![],
497                whole_object_uses: vec![],
498                has_cjs_exports: false,
499                unused_import_bindings: FxHashSet::default(),
500            }];
501            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
502            graph.modules[0].exports = vec![ExportSymbol {
503                name: ExportName::Named("myExport".to_string()),
504                is_type_only: false,
505                is_public: false,
506                span: Span::new(10, 30),
507                references: vec![],
508                members: vec![],
509            }];
510
511            let rules = RulesConfig::default();
512            let config = make_config_with_rules(rules);
513
514            // Without collect_usages
515            let results_no_collect = find_dead_code_full(
516                &graph,
517                &config,
518                &[],
519                None,
520                &[],
521                &[],
522                false, // collect_usages = false
523            );
524            assert!(
525                results_no_collect.export_usages.is_empty(),
526                "export_usages should be empty when collect_usages is false"
527            );
528
529            // With collect_usages
530            let results_with_collect = find_dead_code_full(
531                &graph,
532                &config,
533                &[],
534                None,
535                &[],
536                &[],
537                true, // collect_usages = true
538            );
539            assert!(
540                !results_with_collect.export_usages.is_empty(),
541                "export_usages should be populated when collect_usages is true"
542            );
543            assert_eq!(
544                results_with_collect.export_usages[0].export_name,
545                "myExport"
546            );
547        }
548
549        #[test]
550        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
551            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
552            use crate::graph::ModuleGraph;
553            use crate::resolve::ResolvedModule;
554            use rustc_hash::FxHashSet;
555
556            let files = vec![DiscoveredFile {
557                id: FileId(0),
558                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
559                size_bytes: 100,
560            }];
561            let entry_points = vec![EntryPoint {
562                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
563                source: EntryPointSource::ManualEntry,
564            }];
565            let resolved = vec![ResolvedModule {
566                file_id: FileId(0),
567                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
568                exports: vec![],
569                re_exports: vec![],
570                resolved_imports: vec![],
571                resolved_dynamic_imports: vec![],
572                resolved_dynamic_patterns: vec![],
573                member_accesses: vec![],
574                whole_object_uses: vec![],
575                has_cjs_exports: false,
576                unused_import_bindings: FxHashSet::default(),
577            }];
578            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
579            let config = make_config_with_rules(RulesConfig::default());
580
581            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
582            let results = find_dead_code(&graph, &config);
583            // The entry point export analysis is skipped, so these should be empty
584            assert!(results.unused_exports.is_empty());
585        }
586
587        #[test]
588        fn suppressions_built_from_modules() {
589            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
590            use crate::extract::ModuleInfo;
591            use crate::graph::ModuleGraph;
592            use crate::resolve::ResolvedModule;
593            use crate::suppress::{IssueKind, Suppression};
594            use rustc_hash::FxHashSet;
595
596            let files = vec![
597                DiscoveredFile {
598                    id: FileId(0),
599                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
600                    size_bytes: 100,
601                },
602                DiscoveredFile {
603                    id: FileId(1),
604                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
605                    size_bytes: 100,
606                },
607            ];
608            let entry_points = vec![EntryPoint {
609                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
610                source: EntryPointSource::ManualEntry,
611            }];
612            let resolved = files
613                .iter()
614                .map(|f| ResolvedModule {
615                    file_id: f.id,
616                    path: f.path.clone(),
617                    exports: vec![],
618                    re_exports: vec![],
619                    resolved_imports: vec![],
620                    resolved_dynamic_imports: vec![],
621                    resolved_dynamic_patterns: vec![],
622                    member_accesses: vec![],
623                    whole_object_uses: vec![],
624                    has_cjs_exports: false,
625                    unused_import_bindings: FxHashSet::default(),
626                })
627                .collect::<Vec<_>>();
628            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
629
630            // Create module info with a file-level suppression for unused files
631            let modules = vec![ModuleInfo {
632                file_id: FileId(1),
633                exports: vec![],
634                imports: vec![],
635                re_exports: vec![],
636                dynamic_imports: vec![],
637                dynamic_import_patterns: vec![],
638                require_calls: vec![],
639                member_accesses: vec![],
640                whole_object_uses: vec![],
641                has_cjs_exports: false,
642                content_hash: 0,
643                suppressions: vec![Suppression {
644                    line: 0,
645                    kind: Some(IssueKind::UnusedFile),
646                }],
647                unused_import_bindings: vec![],
648                line_offsets: vec![],
649                complexity: vec![],
650            }];
651
652            let rules = RulesConfig {
653                unused_files: Severity::Error,
654                ..RulesConfig::default()
655            };
656            let config = make_config_with_rules(rules);
657
658            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
659
660            // The suppression should prevent utils.ts from being reported as unused
661            // (it would normally be unused since only entry.ts is an entry point).
662            // Note: unused_files also checks if the file exists on disk, so it
663            // may still be filtered out. The key is the suppression path is exercised.
664            assert!(
665                !results
666                    .unused_files
667                    .iter()
668                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
669                "suppressed file should not appear in unused_files"
670            );
671        }
672    }
673}