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_type_only_dependencies, find_unlisted_dependencies, find_unresolved_imports,
21    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    // Detect circular dependencies
207    if config.rules.circular_dependencies != Severity::Off {
208        let cycles = graph.find_cycles();
209        results.circular_dependencies = cycles
210            .into_iter()
211            .filter(|cycle| {
212                // Skip cycles where any participating file has a file-level suppression
213                !cycle.iter().any(|&id| {
214                    suppressions_by_file.get(&id).is_some_and(|supps| {
215                        suppress::is_file_suppressed(supps, IssueKind::CircularDependency)
216                    })
217                })
218            })
219            .map(|cycle| {
220                let files: Vec<std::path::PathBuf> = cycle
221                    .iter()
222                    .map(|&id| graph.modules[id.0 as usize].path.clone())
223                    .collect();
224                let length = files.len();
225                // Look up the import span from cycle[0] → cycle[1] for precise location
226                let (line, col) = if cycle.len() >= 2 {
227                    graph
228                        .find_import_span_start(cycle[0], cycle[1])
229                        .map_or((1, 0), |span_start| {
230                            byte_offset_to_line_col(&line_offsets_by_file, cycle[0], span_start)
231                        })
232                } else {
233                    (1, 0)
234                };
235                CircularDependency {
236                    files,
237                    length,
238                    line,
239                    col,
240                }
241            })
242            .collect();
243    }
244
245    // Collect export usage counts for Code Lens (LSP feature).
246    // Skipped in CLI mode since the field is #[serde(skip)] in all output formats.
247    if collect_usages {
248        results.export_usages = collect_export_usages(graph, &line_offsets_by_file);
249    }
250
251    results
252}
253
254#[cfg(test)]
255mod tests {
256    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
257
258    // Helper: compute line offsets from source and convert byte offset
259    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
260        let offsets = compute_line_offsets(source);
261        byte_offset_to_line_col(&offsets, byte_offset)
262    }
263
264    // ── compute_line_offsets ─────────────────────────────────────
265
266    #[test]
267    fn compute_offsets_empty() {
268        assert_eq!(compute_line_offsets(""), vec![0]);
269    }
270
271    #[test]
272    fn compute_offsets_single_line() {
273        assert_eq!(compute_line_offsets("hello"), vec![0]);
274    }
275
276    #[test]
277    fn compute_offsets_multiline() {
278        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
279    }
280
281    #[test]
282    fn compute_offsets_trailing_newline() {
283        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
284    }
285
286    #[test]
287    fn compute_offsets_crlf() {
288        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
289    }
290
291    #[test]
292    fn compute_offsets_consecutive_newlines() {
293        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
294    }
295
296    // ── byte_offset_to_line_col ─────────────────────────────────
297
298    #[test]
299    fn byte_offset_empty_source() {
300        assert_eq!(line_col("", 0), (1, 0));
301    }
302
303    #[test]
304    fn byte_offset_single_line_start() {
305        assert_eq!(line_col("hello", 0), (1, 0));
306    }
307
308    #[test]
309    fn byte_offset_single_line_middle() {
310        assert_eq!(line_col("hello", 4), (1, 4));
311    }
312
313    #[test]
314    fn byte_offset_multiline_start_of_line2() {
315        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
316    }
317
318    #[test]
319    fn byte_offset_multiline_middle_of_line3() {
320        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
321    }
322
323    #[test]
324    fn byte_offset_at_newline_boundary() {
325        assert_eq!(line_col("line1\nline2", 5), (1, 5));
326    }
327
328    #[test]
329    fn byte_offset_multibyte_utf8() {
330        let source = "hi\n\u{1F600}x";
331        assert_eq!(line_col(source, 3), (2, 0));
332        assert_eq!(line_col(source, 7), (2, 4));
333    }
334
335    #[test]
336    fn byte_offset_multibyte_accented_chars() {
337        let source = "caf\u{00E9}\nbar";
338        assert_eq!(line_col(source, 6), (2, 0));
339        assert_eq!(line_col(source, 3), (1, 3));
340    }
341
342    #[test]
343    fn byte_offset_via_map_fallback() {
344        use super::*;
345        let map: LineOffsetsMap<'_> = FxHashMap::default();
346        assert_eq!(
347            super::byte_offset_to_line_col(&map, FileId(99), 42),
348            (1, 42)
349        );
350    }
351
352    #[test]
353    fn byte_offset_via_map_lookup() {
354        use super::*;
355        let offsets = compute_line_offsets("abc\ndef\nghi");
356        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
357        map.insert(FileId(0), &offsets);
358        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
359    }
360
361    // ── find_dead_code orchestration ──────────────────────────────
362
363    mod orchestration {
364        use super::super::*;
365        use fallow_config::{
366            DuplicatesConfig, FallowConfig, HealthConfig, OutputFormat, RulesConfig, Severity,
367        };
368        use std::path::PathBuf;
369
370        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
371            FallowConfig {
372                schema: None,
373                extends: vec![],
374                entry: vec![],
375                ignore_patterns: vec![],
376                framework: vec![],
377                workspaces: None,
378                ignore_dependencies: vec![],
379                ignore_exports: vec![],
380                duplicates: DuplicatesConfig::default(),
381                health: HealthConfig::default(),
382                rules,
383                production: false,
384                plugins: vec![],
385                overrides: vec![],
386            }
387            .resolve(
388                PathBuf::from("/tmp/orchestration-test"),
389                OutputFormat::Human,
390                1,
391                true,
392                true,
393            )
394        }
395
396        #[test]
397        fn find_dead_code_all_rules_off_returns_empty() {
398            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
399            use crate::graph::ModuleGraph;
400            use crate::resolve::ResolvedModule;
401            use rustc_hash::FxHashSet;
402
403            let files = vec![DiscoveredFile {
404                id: FileId(0),
405                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
406                size_bytes: 100,
407            }];
408            let entry_points = vec![EntryPoint {
409                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
410                source: EntryPointSource::ManualEntry,
411            }];
412            let resolved = vec![ResolvedModule {
413                file_id: FileId(0),
414                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
415                exports: vec![],
416                re_exports: vec![],
417                resolved_imports: vec![],
418                resolved_dynamic_imports: vec![],
419                resolved_dynamic_patterns: vec![],
420                member_accesses: vec![],
421                whole_object_uses: vec![],
422                has_cjs_exports: false,
423                unused_import_bindings: FxHashSet::default(),
424            }];
425            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
426
427            let rules = RulesConfig {
428                unused_files: Severity::Off,
429                unused_exports: Severity::Off,
430                unused_types: Severity::Off,
431                unused_dependencies: Severity::Off,
432                unused_dev_dependencies: Severity::Off,
433                unused_optional_dependencies: Severity::Off,
434                unused_enum_members: Severity::Off,
435                unused_class_members: Severity::Off,
436                unresolved_imports: Severity::Off,
437                unlisted_dependencies: Severity::Off,
438                duplicate_exports: Severity::Off,
439                type_only_dependencies: Severity::Off,
440                circular_dependencies: Severity::Off,
441            };
442            let config = make_config_with_rules(rules);
443            let results = find_dead_code(&graph, &config);
444
445            assert!(results.unused_files.is_empty());
446            assert!(results.unused_exports.is_empty());
447            assert!(results.unused_types.is_empty());
448            assert!(results.unused_dependencies.is_empty());
449            assert!(results.unused_dev_dependencies.is_empty());
450            assert!(results.unused_optional_dependencies.is_empty());
451            assert!(results.unused_enum_members.is_empty());
452            assert!(results.unused_class_members.is_empty());
453            assert!(results.unresolved_imports.is_empty());
454            assert!(results.unlisted_dependencies.is_empty());
455            assert!(results.duplicate_exports.is_empty());
456            assert!(results.circular_dependencies.is_empty());
457            assert!(results.export_usages.is_empty());
458        }
459
460        #[test]
461        fn find_dead_code_full_collect_usages_flag() {
462            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
463            use crate::extract::ExportName;
464            use crate::graph::{ExportSymbol, ModuleGraph};
465            use crate::resolve::ResolvedModule;
466            use oxc_span::Span;
467            use rustc_hash::FxHashSet;
468
469            let files = vec![DiscoveredFile {
470                id: FileId(0),
471                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
472                size_bytes: 100,
473            }];
474            let entry_points = vec![EntryPoint {
475                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
476                source: EntryPointSource::ManualEntry,
477            }];
478            let resolved = vec![ResolvedModule {
479                file_id: FileId(0),
480                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
481                exports: vec![],
482                re_exports: vec![],
483                resolved_imports: vec![],
484                resolved_dynamic_imports: vec![],
485                resolved_dynamic_patterns: vec![],
486                member_accesses: vec![],
487                whole_object_uses: vec![],
488                has_cjs_exports: false,
489                unused_import_bindings: FxHashSet::default(),
490            }];
491            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
492            graph.modules[0].exports = vec![ExportSymbol {
493                name: ExportName::Named("myExport".to_string()),
494                is_type_only: false,
495                is_public: false,
496                span: Span::new(10, 30),
497                references: vec![],
498                members: vec![],
499            }];
500
501            let rules = RulesConfig::default();
502            let config = make_config_with_rules(rules);
503
504            // Without collect_usages
505            let results_no_collect = find_dead_code_full(
506                &graph,
507                &config,
508                &[],
509                None,
510                &[],
511                &[],
512                false, // collect_usages = false
513            );
514            assert!(
515                results_no_collect.export_usages.is_empty(),
516                "export_usages should be empty when collect_usages is false"
517            );
518
519            // With collect_usages
520            let results_with_collect = find_dead_code_full(
521                &graph,
522                &config,
523                &[],
524                None,
525                &[],
526                &[],
527                true, // collect_usages = true
528            );
529            assert!(
530                !results_with_collect.export_usages.is_empty(),
531                "export_usages should be populated when collect_usages is true"
532            );
533            assert_eq!(
534                results_with_collect.export_usages[0].export_name,
535                "myExport"
536            );
537        }
538
539        #[test]
540        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
541            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
542            use crate::graph::ModuleGraph;
543            use crate::resolve::ResolvedModule;
544            use rustc_hash::FxHashSet;
545
546            let files = vec![DiscoveredFile {
547                id: FileId(0),
548                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
549                size_bytes: 100,
550            }];
551            let entry_points = vec![EntryPoint {
552                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
553                source: EntryPointSource::ManualEntry,
554            }];
555            let resolved = vec![ResolvedModule {
556                file_id: FileId(0),
557                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
558                exports: vec![],
559                re_exports: vec![],
560                resolved_imports: vec![],
561                resolved_dynamic_imports: vec![],
562                resolved_dynamic_patterns: vec![],
563                member_accesses: vec![],
564                whole_object_uses: vec![],
565                has_cjs_exports: false,
566                unused_import_bindings: FxHashSet::default(),
567            }];
568            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
569            let config = make_config_with_rules(RulesConfig::default());
570
571            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
572            let results = find_dead_code(&graph, &config);
573            // The entry point export analysis is skipped, so these should be empty
574            assert!(results.unused_exports.is_empty());
575        }
576
577        #[test]
578        fn suppressions_built_from_modules() {
579            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
580            use crate::extract::ModuleInfo;
581            use crate::graph::ModuleGraph;
582            use crate::resolve::ResolvedModule;
583            use crate::suppress::{IssueKind, Suppression};
584            use rustc_hash::FxHashSet;
585
586            let files = vec![
587                DiscoveredFile {
588                    id: FileId(0),
589                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
590                    size_bytes: 100,
591                },
592                DiscoveredFile {
593                    id: FileId(1),
594                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
595                    size_bytes: 100,
596                },
597            ];
598            let entry_points = vec![EntryPoint {
599                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
600                source: EntryPointSource::ManualEntry,
601            }];
602            let resolved = files
603                .iter()
604                .map(|f| ResolvedModule {
605                    file_id: f.id,
606                    path: f.path.clone(),
607                    exports: vec![],
608                    re_exports: vec![],
609                    resolved_imports: vec![],
610                    resolved_dynamic_imports: vec![],
611                    resolved_dynamic_patterns: vec![],
612                    member_accesses: vec![],
613                    whole_object_uses: vec![],
614                    has_cjs_exports: false,
615                    unused_import_bindings: FxHashSet::default(),
616                })
617                .collect::<Vec<_>>();
618            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
619
620            // Create module info with a file-level suppression for unused files
621            let modules = vec![ModuleInfo {
622                file_id: FileId(1),
623                exports: vec![],
624                imports: vec![],
625                re_exports: vec![],
626                dynamic_imports: vec![],
627                dynamic_import_patterns: vec![],
628                require_calls: vec![],
629                member_accesses: vec![],
630                whole_object_uses: vec![],
631                has_cjs_exports: false,
632                content_hash: 0,
633                suppressions: vec![Suppression {
634                    line: 0,
635                    kind: Some(IssueKind::UnusedFile),
636                }],
637                unused_import_bindings: vec![],
638                line_offsets: vec![],
639                complexity: vec![],
640            }];
641
642            let rules = RulesConfig {
643                unused_files: Severity::Error,
644                ..RulesConfig::default()
645            };
646            let config = make_config_with_rules(rules);
647
648            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
649
650            // The suppression should prevent utils.ts from being reported as unused
651            // (it would normally be unused since only entry.ts is an entry point).
652            // Note: unused_files also checks if the file exists on disk, so it
653            // may still be filtered out. The key is the suppression path is exercised.
654            assert!(
655                !results
656                    .unused_files
657                    .iter()
658                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
659                "suppressed file should not appear in unused_files"
660            );
661        }
662    }
663}