Skip to main content

fallow_core/analyze/
mod.rs

1mod boundary;
2pub mod feature_flags;
3mod package_json_utils;
4mod predicates;
5mod unused_catalog;
6mod unused_deps;
7mod unused_exports;
8mod unused_files;
9mod unused_members;
10mod unused_overrides;
11
12use rustc_hash::FxHashMap;
13
14use fallow_config::{PackageJson, ResolvedConfig, Severity};
15
16use crate::discover::FileId;
17use crate::extract::ModuleInfo;
18use crate::graph::ModuleGraph;
19use crate::resolve::ResolvedModule;
20use fallow_types::output_dead_code::{
21    BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
22    EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeakFinding,
23    TestOnlyDependencyFinding, TypeOnlyDependencyFinding, UnlistedDependencyFinding,
24    UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
25    UnusedClassMemberFinding, UnusedDependencyFinding, UnusedDependencyOverrideFinding,
26    UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
27    UnusedOptionalDependencyFinding, UnusedTypeFinding,
28};
29
30use crate::results::{AnalysisResults, CircularDependency};
31use crate::suppress::IssueKind;
32
33#[expect(
34    deprecated,
35    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
36)]
37use unused_catalog::{
38    find_empty_catalog_groups, find_unresolved_catalog_references, find_unused_catalog_entries,
39    gather_pnpm_catalog_state,
40};
41#[expect(
42    deprecated,
43    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
44)]
45use unused_deps::{
46    find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
47    find_unresolved_imports, find_unused_dependencies,
48};
49#[expect(
50    deprecated,
51    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
52)]
53use unused_exports::{
54    collect_export_usages, find_duplicate_exports, find_private_type_leaks, find_unused_exports,
55    suppress_signature_backing_types,
56};
57#[expect(
58    deprecated,
59    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
60)]
61use unused_files::find_unused_files;
62#[expect(
63    deprecated,
64    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
65)]
66use unused_members::find_unused_members;
67#[expect(
68    deprecated,
69    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
70)]
71use unused_overrides::{
72    find_misconfigured_dependency_overrides, find_unused_dependency_overrides,
73    gather_pnpm_override_state,
74};
75
76/// Pre-computed line offset tables indexed by `FileId`, built during parse and
77/// carried through the cache. Eliminates redundant file reads during analysis.
78#[doc(hidden)]
79pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
80
81/// Convert a byte offset to (line, col) using pre-computed line offsets.
82/// Falls back to `(1, byte_offset)` when no line table is available.
83#[doc(hidden)]
84pub fn byte_offset_to_line_col(
85    line_offsets_map: &LineOffsetsMap<'_>,
86    file_id: FileId,
87    byte_offset: u32,
88) -> (u32, u32) {
89    line_offsets_map
90        .get(&file_id)
91        .map_or((1, byte_offset), |offsets| {
92            fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
93        })
94}
95
96fn cycle_edge_line_col(
97    graph: &ModuleGraph,
98    line_offsets_map: &LineOffsetsMap<'_>,
99    cycle: &[FileId],
100    edge_index: usize,
101) -> Option<(u32, u32)> {
102    if cycle.is_empty() {
103        return None;
104    }
105
106    let from = cycle[edge_index];
107    let to = cycle[(edge_index + 1) % cycle.len()];
108    graph
109        .find_import_span_start(from, to)
110        .map(|span_start| byte_offset_to_line_col(line_offsets_map, from, span_start))
111}
112
113fn is_circular_dependency_suppressed(
114    graph: &ModuleGraph,
115    line_offsets_map: &LineOffsetsMap<'_>,
116    suppressions: &crate::suppress::SuppressionContext<'_>,
117    cycle: &[FileId],
118) -> bool {
119    if cycle
120        .iter()
121        .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
122    {
123        return true;
124    }
125
126    let mut line_suppressed = false;
127    for edge_index in 0..cycle.len() {
128        let from = cycle[edge_index];
129        if let Some((line, _)) = cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index)
130            && suppressions.is_suppressed(from, line, IssueKind::CircularDependency)
131        {
132            line_suppressed = true;
133        }
134    }
135    line_suppressed
136}
137
138/// Read source content from disk, returning empty string on failure.
139/// Only used for LSP Code Lens reference resolution where the referencing
140/// file may not be in the line offsets map.
141fn read_source(path: &std::path::Path) -> String {
142    std::fs::read_to_string(path).unwrap_or_default()
143}
144
145/// Check whether any two files in a cycle belong to different workspace packages.
146/// Uses longest-prefix-match to assign each file to a workspace root.
147/// Files outside all workspace roots (e.g., root-level shared code) are ignored —
148/// only cycles between two distinct named workspaces are flagged.
149fn is_cross_package_cycle(
150    files: &[std::path::PathBuf],
151    workspaces: &[fallow_config::WorkspaceInfo],
152) -> bool {
153    let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
154        workspaces
155            .iter()
156            .map(|w| w.root.as_path())
157            .filter(|root| path.starts_with(root))
158            .max_by_key(|root| root.components().count())
159    };
160
161    let mut seen_workspace: Option<&std::path::Path> = None;
162    for file in files {
163        if let Some(ws) = find_workspace(file) {
164            match &seen_workspace {
165                None => seen_workspace = Some(ws),
166                Some(prev) if *prev != ws => return true,
167                _ => {}
168            }
169        }
170    }
171    false
172}
173
174fn public_workspace_roots<'a>(
175    public_packages: &[String],
176    workspaces: &'a [fallow_config::WorkspaceInfo],
177) -> Vec<&'a std::path::Path> {
178    if public_packages.is_empty() || workspaces.is_empty() {
179        return Vec::new();
180    }
181
182    workspaces
183        .iter()
184        .filter(|ws| {
185            public_packages.iter().any(|pattern| {
186                ws.name == *pattern
187                    || globset::Glob::new(pattern)
188                        .ok()
189                        .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
190            })
191        })
192        .map(|ws| ws.root.as_path())
193        .collect()
194}
195
196fn find_circular_dependencies(
197    graph: &ModuleGraph,
198    line_offsets_map: &LineOffsetsMap<'_>,
199    suppressions: &crate::suppress::SuppressionContext<'_>,
200    workspaces: &[fallow_config::WorkspaceInfo],
201) -> Vec<CircularDependency> {
202    let cycles = graph.find_cycles();
203    let mut dependencies: Vec<CircularDependency> = cycles
204        .into_iter()
205        .filter_map(|cycle| {
206            if is_circular_dependency_suppressed(graph, line_offsets_map, suppressions, &cycle) {
207                return None;
208            }
209
210            let files: Vec<std::path::PathBuf> = cycle
211                .iter()
212                .map(|&id| graph.modules[id.0 as usize].path.clone())
213                .collect();
214            let length = files.len();
215            // Look up the import span from cycle[0] -> cycle[1] for precise location.
216            let (line, col) =
217                cycle_edge_line_col(graph, line_offsets_map, &cycle, 0).unwrap_or((1, 0));
218            Some(CircularDependency {
219                files,
220                length,
221                line,
222                col,
223                is_cross_package: false,
224            })
225        })
226        .collect();
227
228    // Mark cycles that cross workspace package boundaries.
229    if !workspaces.is_empty() {
230        for dep in &mut dependencies {
231            dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
232        }
233    }
234
235    dependencies
236}
237
238/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
239#[expect(
240    deprecated,
241    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
242)]
243#[deprecated(
244    since = "2.76.0",
245    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
246)]
247#[expect(
248    clippy::too_many_lines,
249    reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
250)]
251pub fn find_dead_code_full(
252    graph: &ModuleGraph,
253    config: &ResolvedConfig,
254    resolved_modules: &[ResolvedModule],
255    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
256    workspaces: &[fallow_config::WorkspaceInfo],
257    modules: &[ModuleInfo],
258    collect_usages: bool,
259) -> AnalysisResults {
260    let _span = tracing::info_span!("find_dead_code").entered();
261
262    // Build suppression context: tracks which suppressions are consumed by detectors
263    let suppressions = crate::suppress::SuppressionContext::new(modules);
264
265    // Build line offset index: FileId -> pre-computed line start offsets.
266    // Eliminates redundant file reads for byte-to-line/col conversion.
267    let line_offsets_by_file: LineOffsetsMap<'_> = modules
268        .iter()
269        .filter(|m| !m.line_offsets.is_empty())
270        .map(|m| (m.file_id, m.line_offsets.as_slice()))
271        .collect();
272
273    // Build merged dependency set from root + all workspace package.json files
274    let pkg_path = config.root.join("package.json");
275    let pkg = PackageJson::load(&pkg_path).ok();
276
277    // Merge the top-level config rules with any plugin-contributed rules.
278    // Plain string entries behave like the old global allowlist; scoped object
279    // entries only apply to classes that match `extends` / `implements`.
280    let mut user_class_members = config.used_class_members.clone();
281    if let Some(plugin_result) = plugin_result {
282        user_class_members.extend(plugin_result.used_class_members.iter().cloned());
283    }
284
285    let virtual_prefixes: Vec<&str> = plugin_result
286        .map(|pr| {
287            pr.virtual_module_prefixes
288                .iter()
289                .map(String::as_str)
290                .collect()
291        })
292        .unwrap_or_default();
293    let generated_patterns: Vec<&str> = plugin_result
294        .map(|pr| {
295            pr.generated_import_patterns
296                .iter()
297                .map(String::as_str)
298                .collect()
299        })
300        .unwrap_or_default();
301
302    let (
303        (unused_files, export_results),
304        (
305            (member_results, dependency_results),
306            (
307                (unresolved_imports, duplicate_exports),
308                (boundary_violations, (circular_dependencies, export_usages)),
309            ),
310        ),
311    ) = rayon::join(
312        || {
313            rayon::join(
314                || {
315                    if config.rules.unused_files != Severity::Off {
316                        find_unused_files(graph, &suppressions)
317                            .into_iter()
318                            .map(UnusedFileFinding::with_actions)
319                            .collect::<Vec<_>>()
320                    } else {
321                        Vec::new()
322                    }
323                },
324                || {
325                    let mut results = AnalysisResults::default();
326                    if config.rules.unused_exports != Severity::Off
327                        || config.rules.unused_types != Severity::Off
328                        || config.rules.private_type_leaks != Severity::Off
329                    {
330                        let (exports, types, stale_expected) = find_unused_exports(
331                            graph,
332                            modules,
333                            config,
334                            plugin_result,
335                            &suppressions,
336                            &line_offsets_by_file,
337                        );
338                        if config.rules.unused_exports != Severity::Off {
339                            results.unused_exports = exports
340                                .into_iter()
341                                .map(UnusedExportFinding::with_actions)
342                                .collect();
343                        }
344                        if config.rules.unused_types != Severity::Off {
345                            let mut typed = types;
346                            suppress_signature_backing_types(&mut typed, graph, modules);
347                            results.unused_types = typed
348                                .into_iter()
349                                .map(UnusedTypeFinding::with_actions)
350                                .collect();
351                        }
352                        if config.rules.private_type_leaks != Severity::Off {
353                            results.private_type_leaks = find_private_type_leaks(
354                                graph,
355                                modules,
356                                config,
357                                &suppressions,
358                                &line_offsets_by_file,
359                            )
360                            .into_iter()
361                            .map(PrivateTypeLeakFinding::with_actions)
362                            .collect();
363                        }
364                        // @expected-unused tags that became stale (export is now used).
365                        if config.rules.stale_suppressions != Severity::Off {
366                            results.stale_suppressions.extend(stale_expected);
367                        }
368                    }
369                    results
370                },
371            )
372        },
373        || {
374            rayon::join(
375                || {
376                    rayon::join(
377                        || {
378                            let mut results = AnalysisResults::default();
379                            if config.rules.unused_enum_members != Severity::Off
380                                || config.rules.unused_class_members != Severity::Off
381                            {
382                                let (enum_members, class_members) = find_unused_members(
383                                    graph,
384                                    resolved_modules,
385                                    modules,
386                                    &suppressions,
387                                    &line_offsets_by_file,
388                                    &user_class_members,
389                                );
390                                if config.rules.unused_enum_members != Severity::Off {
391                                    results.unused_enum_members = enum_members
392                                        .into_iter()
393                                        .map(UnusedEnumMemberFinding::with_actions)
394                                        .collect();
395                                }
396                                if config.rules.unused_class_members != Severity::Off {
397                                    results.unused_class_members = class_members
398                                        .into_iter()
399                                        .map(UnusedClassMemberFinding::with_actions)
400                                        .collect();
401                                }
402                            }
403                            results
404                        },
405                        || {
406                            let mut results = AnalysisResults::default();
407                            if let Some(ref pkg) = pkg {
408                                if config.rules.unused_dependencies != Severity::Off
409                                    || config.rules.unused_dev_dependencies != Severity::Off
410                                    || config.rules.unused_optional_dependencies != Severity::Off
411                                {
412                                    let (deps, dev_deps, optional_deps) = find_unused_dependencies(
413                                        graph,
414                                        pkg,
415                                        config,
416                                        plugin_result,
417                                        workspaces,
418                                    );
419                                    if config.rules.unused_dependencies != Severity::Off {
420                                        results.unused_dependencies = deps
421                                            .into_iter()
422                                            .map(UnusedDependencyFinding::with_actions)
423                                            .collect();
424                                    }
425                                    if config.rules.unused_dev_dependencies != Severity::Off {
426                                        results.unused_dev_dependencies = dev_deps
427                                            .into_iter()
428                                            .map(UnusedDevDependencyFinding::with_actions)
429                                            .collect();
430                                    }
431                                    if config.rules.unused_optional_dependencies != Severity::Off {
432                                        results.unused_optional_dependencies = optional_deps
433                                            .into_iter()
434                                            .map(UnusedOptionalDependencyFinding::with_actions)
435                                            .collect();
436                                    }
437                                }
438
439                                if config.rules.unlisted_dependencies != Severity::Off {
440                                    results.unlisted_dependencies = find_unlisted_dependencies(
441                                        graph,
442                                        pkg,
443                                        config,
444                                        workspaces,
445                                        plugin_result,
446                                        resolved_modules,
447                                        &line_offsets_by_file,
448                                    )
449                                    .into_iter()
450                                    .map(UnlistedDependencyFinding::with_actions)
451                                    .collect();
452                                }
453
454                                // In production mode, detect dependencies that are only used via
455                                // type-only imports.
456                                if config.production {
457                                    results.type_only_dependencies =
458                                        find_type_only_dependencies(graph, pkg, config, workspaces)
459                                            .into_iter()
460                                            .map(TypeOnlyDependencyFinding::with_actions)
461                                            .collect();
462                                }
463
464                                // In non-production mode, detect production deps only imported by
465                                // test/dev files.
466                                if !config.production
467                                    && config.rules.test_only_dependencies != Severity::Off
468                                {
469                                    results.test_only_dependencies =
470                                        find_test_only_dependencies(graph, pkg, config, workspaces)
471                                            .into_iter()
472                                            .map(TestOnlyDependencyFinding::with_actions)
473                                            .collect();
474                                }
475                            }
476                            results
477                        },
478                    )
479                },
480                || {
481                    rayon::join(
482                        || {
483                            rayon::join(
484                                || {
485                                    if config.rules.unresolved_imports != Severity::Off
486                                        && !resolved_modules.is_empty()
487                                    {
488                                        find_unresolved_imports(
489                                            resolved_modules,
490                                            config,
491                                            &suppressions,
492                                            &virtual_prefixes,
493                                            &generated_patterns,
494                                            &line_offsets_by_file,
495                                        )
496                                        .into_iter()
497                                        .map(UnresolvedImportFinding::with_actions)
498                                        .collect::<Vec<_>>()
499                                    } else {
500                                        Vec::new()
501                                    }
502                                },
503                                || {
504                                    if config.rules.duplicate_exports != Severity::Off {
505                                        find_duplicate_exports(
506                                            graph,
507                                            config,
508                                            &suppressions,
509                                            &line_offsets_by_file,
510                                            resolved_modules,
511                                        )
512                                        .into_iter()
513                                        .map(DuplicateExportFinding::with_actions)
514                                        .collect::<Vec<_>>()
515                                    } else {
516                                        Vec::new()
517                                    }
518                                },
519                            )
520                        },
521                        || {
522                            rayon::join(
523                                || {
524                                    if config.rules.boundary_violation != Severity::Off
525                                        && !config.boundaries.is_empty()
526                                    {
527                                        boundary::find_boundary_violations(
528                                            graph,
529                                            config,
530                                            &suppressions,
531                                            &line_offsets_by_file,
532                                        )
533                                        .into_iter()
534                                        .map(BoundaryViolationFinding::with_actions)
535                                        .collect::<Vec<_>>()
536                                    } else {
537                                        Vec::new()
538                                    }
539                                },
540                                || {
541                                    rayon::join(
542                                        || {
543                                            if config.rules.circular_dependencies != Severity::Off {
544                                                find_circular_dependencies(
545                                                    graph,
546                                                    &line_offsets_by_file,
547                                                    &suppressions,
548                                                    workspaces,
549                                                )
550                                                .into_iter()
551                                                .map(CircularDependencyFinding::with_actions)
552                                                .collect::<Vec<_>>()
553                                            } else {
554                                                Vec::new()
555                                            }
556                                        },
557                                        || {
558                                            // Collect export usage counts for Code Lens (LSP
559                                            // feature). Skipped in CLI mode since the field is
560                                            // #[serde(skip)] in all output formats.
561                                            if collect_usages {
562                                                collect_export_usages(graph, &line_offsets_by_file)
563                                            } else {
564                                                Vec::new()
565                                            }
566                                        },
567                                    )
568                                },
569                            )
570                        },
571                    )
572                },
573            )
574        },
575    );
576
577    let mut results = AnalysisResults {
578        unused_files,
579        unused_exports: export_results.unused_exports,
580        unused_types: export_results.unused_types,
581        private_type_leaks: export_results.private_type_leaks,
582        stale_suppressions: export_results.stale_suppressions,
583        unused_enum_members: member_results.unused_enum_members,
584        unused_class_members: member_results.unused_class_members,
585        unused_dependencies: dependency_results.unused_dependencies,
586        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
587        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
588        unlisted_dependencies: dependency_results.unlisted_dependencies,
589        type_only_dependencies: dependency_results.type_only_dependencies,
590        test_only_dependencies: dependency_results.test_only_dependencies,
591        unresolved_imports,
592        duplicate_exports,
593        boundary_violations,
594        circular_dependencies,
595        export_usages,
596        ..AnalysisResults::default()
597    };
598
599    // Filter out exported API surface from public packages.
600    // Public packages are workspace packages whose exports are intended for external consumers.
601    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
602    if !public_roots.is_empty() {
603        results.unused_exports.retain(|e| {
604            !public_roots
605                .iter()
606                .any(|root| e.export.path.starts_with(root))
607        });
608        results.unused_types.retain(|e| {
609            !public_roots
610                .iter()
611                .any(|root| e.export.path.starts_with(root))
612        });
613        results.unused_enum_members.retain(|e| {
614            !public_roots
615                .iter()
616                .any(|root| e.member.path.starts_with(root))
617        });
618        results.unused_class_members.retain(|e| {
619            !public_roots
620                .iter()
621                .any(|root| e.member.path.starts_with(root))
622        });
623    }
624
625    // Detect stale suppression comments (must run after all detectors)
626    if config.rules.stale_suppressions != Severity::Off {
627        results
628            .stale_suppressions
629            .extend(suppressions.find_stale(graph));
630    }
631    results.suppression_count = suppressions.used_count();
632
633    // Detect pnpm catalog issues (purely off package.json + pnpm-workspace.yaml).
634    // Catalog detectors share the YAML parse and consumer walk; gather state
635    // once and run each detector gated on its own rule severity.
636    let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
637    let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
638    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
639    if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
640        && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
641    {
642        if need_unused_catalogs {
643            results.unused_catalog_entries = find_unused_catalog_entries(&state)
644                .into_iter()
645                .map(UnusedCatalogEntryFinding::with_actions)
646                .collect();
647        }
648        if need_empty_catalog_groups {
649            results.empty_catalog_groups = find_empty_catalog_groups(&state)
650                .into_iter()
651                .map(EmptyCatalogGroupFinding::with_actions)
652                .collect();
653        }
654        if need_unresolved_refs {
655            results.unresolved_catalog_references = find_unresolved_catalog_references(
656                &state,
657                &config.compiled_ignore_catalog_references,
658                &config.root,
659            )
660            .into_iter()
661            .map(UnresolvedCatalogReferenceFinding::with_actions)
662            .collect();
663        }
664    }
665
666    // Detect pnpm dependency-override issues (off pnpm-workspace.yaml +
667    // root package.json's pnpm.overrides). Mirrors the catalog detector: one
668    // parse + workspace walk feeds both unused-dependency-overrides and
669    // misconfigured-dependency-overrides; each detector gated on its own
670    // rule severity.
671    let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
672    let need_misconfigured_overrides =
673        config.rules.misconfigured_dependency_overrides != Severity::Off;
674    if (need_unused_overrides || need_misconfigured_overrides)
675        && let Some(state) = gather_pnpm_override_state(config, workspaces)
676    {
677        if need_unused_overrides {
678            results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
679                .into_iter()
680                .map(UnusedDependencyOverrideFinding::with_actions)
681                .collect();
682        }
683        if need_misconfigured_overrides {
684            results.misconfigured_dependency_overrides =
685                find_misconfigured_dependency_overrides(&state, config)
686                    .into_iter()
687                    .map(MisconfiguredDependencyOverrideFinding::with_actions)
688                    .collect();
689        }
690    }
691
692    // Sort all result arrays for deterministic output ordering.
693    // Parallel collection and FxHashMap iteration don't guarantee order,
694    // so without sorting the same project can produce different orderings.
695    results.sort();
696
697    results
698}
699
700#[cfg(test)]
701#[expect(
702    deprecated,
703    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
704)]
705mod tests {
706    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
707
708    // Helper: compute line offsets from source and convert byte offset
709    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
710        let offsets = compute_line_offsets(source);
711        byte_offset_to_line_col(&offsets, byte_offset)
712    }
713
714    // ── compute_line_offsets ─────────────────────────────────────
715
716    #[test]
717    fn compute_offsets_empty() {
718        assert_eq!(compute_line_offsets(""), vec![0]);
719    }
720
721    #[test]
722    fn compute_offsets_single_line() {
723        assert_eq!(compute_line_offsets("hello"), vec![0]);
724    }
725
726    #[test]
727    fn compute_offsets_multiline() {
728        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
729    }
730
731    #[test]
732    fn compute_offsets_trailing_newline() {
733        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
734    }
735
736    #[test]
737    fn compute_offsets_crlf() {
738        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
739    }
740
741    #[test]
742    fn compute_offsets_consecutive_newlines() {
743        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
744    }
745
746    // ── byte_offset_to_line_col ─────────────────────────────────
747
748    #[test]
749    fn byte_offset_empty_source() {
750        assert_eq!(line_col("", 0), (1, 0));
751    }
752
753    #[test]
754    fn byte_offset_single_line_start() {
755        assert_eq!(line_col("hello", 0), (1, 0));
756    }
757
758    #[test]
759    fn byte_offset_single_line_middle() {
760        assert_eq!(line_col("hello", 4), (1, 4));
761    }
762
763    #[test]
764    fn byte_offset_multiline_start_of_line2() {
765        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
766    }
767
768    #[test]
769    fn byte_offset_multiline_middle_of_line3() {
770        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
771    }
772
773    #[test]
774    fn byte_offset_at_newline_boundary() {
775        assert_eq!(line_col("line1\nline2", 5), (1, 5));
776    }
777
778    #[test]
779    fn byte_offset_multibyte_utf8() {
780        let source = "hi\n\u{1F600}x";
781        assert_eq!(line_col(source, 3), (2, 0));
782        assert_eq!(line_col(source, 7), (2, 4));
783    }
784
785    #[test]
786    fn byte_offset_multibyte_accented_chars() {
787        let source = "caf\u{00E9}\nbar";
788        assert_eq!(line_col(source, 6), (2, 0));
789        assert_eq!(line_col(source, 3), (1, 3));
790    }
791
792    #[test]
793    fn byte_offset_via_map_fallback() {
794        use super::*;
795        let map: LineOffsetsMap<'_> = FxHashMap::default();
796        assert_eq!(
797            super::byte_offset_to_line_col(&map, FileId(99), 42),
798            (1, 42)
799        );
800    }
801
802    #[test]
803    fn byte_offset_via_map_lookup() {
804        use super::*;
805        let offsets = compute_line_offsets("abc\ndef\nghi");
806        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
807        map.insert(FileId(0), &offsets);
808        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
809    }
810
811    // ── find_dead_code orchestration ──────────────────────────────
812
813    mod orchestration {
814        use super::super::*;
815        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
816        use std::path::PathBuf;
817
818        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
819            find_dead_code_full(graph, config, &[], None, &[], &[], false)
820        }
821
822        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
823            FallowConfig {
824                rules,
825                ..Default::default()
826            }
827            .resolve(
828                PathBuf::from("/tmp/orchestration-test"),
829                OutputFormat::Human,
830                1,
831                true,
832                true,
833            )
834        }
835
836        #[test]
837        fn find_dead_code_all_rules_off_returns_empty() {
838            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
839            use crate::graph::ModuleGraph;
840            use crate::resolve::ResolvedModule;
841            use rustc_hash::FxHashSet;
842
843            let files = vec![DiscoveredFile {
844                id: FileId(0),
845                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
846                size_bytes: 100,
847            }];
848            let entry_points = vec![EntryPoint {
849                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
850                source: EntryPointSource::ManualEntry,
851            }];
852            let resolved = vec![ResolvedModule {
853                file_id: FileId(0),
854                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
855                exports: vec![],
856                re_exports: vec![],
857                resolved_imports: vec![],
858                resolved_dynamic_imports: vec![],
859                resolved_dynamic_patterns: vec![],
860                member_accesses: vec![],
861                whole_object_uses: vec![],
862                has_cjs_exports: false,
863                has_angular_component_template_url: false,
864                unused_import_bindings: FxHashSet::default(),
865                type_referenced_import_bindings: vec![],
866                value_referenced_import_bindings: vec![],
867                namespace_object_aliases: vec![],
868            }];
869            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
870
871            let rules = RulesConfig {
872                unused_files: Severity::Off,
873                unused_exports: Severity::Off,
874                unused_types: Severity::Off,
875                private_type_leaks: Severity::Off,
876                unused_dependencies: Severity::Off,
877                unused_dev_dependencies: Severity::Off,
878                unused_optional_dependencies: Severity::Off,
879                unused_enum_members: Severity::Off,
880                unused_class_members: Severity::Off,
881                unresolved_imports: Severity::Off,
882                unlisted_dependencies: Severity::Off,
883                duplicate_exports: Severity::Off,
884                type_only_dependencies: Severity::Off,
885                circular_dependencies: Severity::Off,
886                test_only_dependencies: Severity::Off,
887                boundary_violation: Severity::Off,
888                coverage_gaps: Severity::Off,
889                feature_flags: Severity::Off,
890                stale_suppressions: Severity::Off,
891                unused_catalog_entries: Severity::Off,
892                empty_catalog_groups: Severity::Off,
893                unresolved_catalog_references: Severity::Off,
894                unused_dependency_overrides: Severity::Off,
895                misconfigured_dependency_overrides: Severity::Off,
896            };
897            let config = make_config_with_rules(rules);
898            let results = find_dead_code(&graph, &config);
899
900            assert!(results.unused_files.is_empty());
901            assert!(results.unused_exports.is_empty());
902            assert!(results.unused_types.is_empty());
903            assert!(results.unused_dependencies.is_empty());
904            assert!(results.unused_dev_dependencies.is_empty());
905            assert!(results.unused_optional_dependencies.is_empty());
906            assert!(results.unused_enum_members.is_empty());
907            assert!(results.unused_class_members.is_empty());
908            assert!(results.unresolved_imports.is_empty());
909            assert!(results.unlisted_dependencies.is_empty());
910            assert!(results.duplicate_exports.is_empty());
911            assert!(results.circular_dependencies.is_empty());
912            assert!(results.export_usages.is_empty());
913        }
914
915        #[test]
916        fn find_dead_code_full_collect_usages_flag() {
917            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
918            use crate::extract::{ExportName, VisibilityTag};
919            use crate::graph::{ExportSymbol, ModuleGraph};
920            use crate::resolve::ResolvedModule;
921            use oxc_span::Span;
922            use rustc_hash::FxHashSet;
923
924            let files = vec![DiscoveredFile {
925                id: FileId(0),
926                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
927                size_bytes: 100,
928            }];
929            let entry_points = vec![EntryPoint {
930                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
931                source: EntryPointSource::ManualEntry,
932            }];
933            let resolved = vec![ResolvedModule {
934                file_id: FileId(0),
935                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
936                exports: vec![],
937                re_exports: vec![],
938                resolved_imports: vec![],
939                resolved_dynamic_imports: vec![],
940                resolved_dynamic_patterns: vec![],
941                member_accesses: vec![],
942                whole_object_uses: vec![],
943                has_cjs_exports: false,
944                has_angular_component_template_url: false,
945                unused_import_bindings: FxHashSet::default(),
946                type_referenced_import_bindings: vec![],
947                value_referenced_import_bindings: vec![],
948                namespace_object_aliases: vec![],
949            }];
950            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
951            graph.modules[0].exports = vec![ExportSymbol {
952                name: ExportName::Named("myExport".to_string()),
953                is_type_only: false,
954                is_side_effect_used: false,
955                visibility: VisibilityTag::None,
956                span: Span::new(10, 30),
957                references: vec![],
958                members: vec![],
959            }];
960
961            let rules = RulesConfig::default();
962            let config = make_config_with_rules(rules);
963
964            // Without collect_usages
965            let results_no_collect = find_dead_code_full(
966                &graph,
967                &config,
968                &[],
969                None,
970                &[],
971                &[],
972                false, // collect_usages = false
973            );
974            assert!(
975                results_no_collect.export_usages.is_empty(),
976                "export_usages should be empty when collect_usages is false"
977            );
978
979            // With collect_usages
980            let results_with_collect = find_dead_code_full(
981                &graph,
982                &config,
983                &[],
984                None,
985                &[],
986                &[],
987                true, // collect_usages = true
988            );
989            assert!(
990                !results_with_collect.export_usages.is_empty(),
991                "export_usages should be populated when collect_usages is true"
992            );
993            assert_eq!(
994                results_with_collect.export_usages[0].export_name,
995                "myExport"
996            );
997        }
998
999        #[test]
1000        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1001            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1002            use crate::graph::ModuleGraph;
1003            use crate::resolve::ResolvedModule;
1004            use rustc_hash::FxHashSet;
1005
1006            let files = vec![DiscoveredFile {
1007                id: FileId(0),
1008                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1009                size_bytes: 100,
1010            }];
1011            let entry_points = vec![EntryPoint {
1012                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1013                source: EntryPointSource::ManualEntry,
1014            }];
1015            let resolved = vec![ResolvedModule {
1016                file_id: FileId(0),
1017                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1018                exports: vec![],
1019                re_exports: vec![],
1020                resolved_imports: vec![],
1021                resolved_dynamic_imports: vec![],
1022                resolved_dynamic_patterns: vec![],
1023                member_accesses: vec![],
1024                whole_object_uses: vec![],
1025                has_cjs_exports: false,
1026                has_angular_component_template_url: false,
1027                unused_import_bindings: FxHashSet::default(),
1028                type_referenced_import_bindings: vec![],
1029                value_referenced_import_bindings: vec![],
1030                namespace_object_aliases: vec![],
1031            }];
1032            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1033            let config = make_config_with_rules(RulesConfig::default());
1034
1035            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
1036            let results = find_dead_code(&graph, &config);
1037            // The entry point export analysis is skipped, so these should be empty
1038            assert!(results.unused_exports.is_empty());
1039        }
1040
1041        #[test]
1042        fn suppressions_built_from_modules() {
1043            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1044            use crate::extract::ModuleInfo;
1045            use crate::graph::ModuleGraph;
1046            use crate::resolve::ResolvedModule;
1047            use crate::suppress::{IssueKind, Suppression};
1048            use rustc_hash::FxHashSet;
1049
1050            let files = vec![
1051                DiscoveredFile {
1052                    id: FileId(0),
1053                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1054                    size_bytes: 100,
1055                },
1056                DiscoveredFile {
1057                    id: FileId(1),
1058                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1059                    size_bytes: 100,
1060                },
1061            ];
1062            let entry_points = vec![EntryPoint {
1063                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1064                source: EntryPointSource::ManualEntry,
1065            }];
1066            let resolved = files
1067                .iter()
1068                .map(|f| ResolvedModule {
1069                    file_id: f.id,
1070                    path: f.path.clone(),
1071                    exports: vec![],
1072                    re_exports: vec![],
1073                    resolved_imports: vec![],
1074                    resolved_dynamic_imports: vec![],
1075                    resolved_dynamic_patterns: vec![],
1076                    member_accesses: vec![],
1077                    whole_object_uses: vec![],
1078                    has_cjs_exports: false,
1079                    has_angular_component_template_url: false,
1080                    unused_import_bindings: FxHashSet::default(),
1081                    type_referenced_import_bindings: vec![],
1082                    value_referenced_import_bindings: vec![],
1083                    namespace_object_aliases: vec![],
1084                })
1085                .collect::<Vec<_>>();
1086            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1087
1088            // Create module info with a file-level suppression for unused files
1089            let modules = vec![ModuleInfo {
1090                file_id: FileId(1),
1091                exports: vec![],
1092                imports: vec![],
1093                re_exports: vec![],
1094                dynamic_imports: vec![],
1095                dynamic_import_patterns: vec![],
1096                require_calls: vec![],
1097                member_accesses: vec![],
1098                whole_object_uses: vec![],
1099                has_cjs_exports: false,
1100                has_angular_component_template_url: false,
1101                content_hash: 0,
1102                suppressions: vec![Suppression {
1103                    line: 0,
1104                    comment_line: 1,
1105                    kind: Some(IssueKind::UnusedFile),
1106                }],
1107                unused_import_bindings: vec![],
1108                type_referenced_import_bindings: vec![],
1109                value_referenced_import_bindings: vec![],
1110                line_offsets: vec![],
1111                complexity: vec![],
1112                flag_uses: vec![],
1113                class_heritage: vec![],
1114                local_type_declarations: Vec::new(),
1115                public_signature_type_references: Vec::new(),
1116                namespace_object_aliases: Vec::new(),
1117            }];
1118
1119            let rules = RulesConfig {
1120                unused_files: Severity::Error,
1121                ..RulesConfig::default()
1122            };
1123            let config = make_config_with_rules(rules);
1124
1125            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1126
1127            // The suppression should prevent utils.ts from being reported as unused
1128            // (it would normally be unused since only entry.ts is an entry point).
1129            // Note: unused_files also checks if the file exists on disk, so it
1130            // may still be filtered out. The key is the suppression path is exercised.
1131            assert!(
1132                !results.unused_files.iter().any(|f| f
1133                    .file
1134                    .path
1135                    .to_string_lossy()
1136                    .contains("utils.ts")),
1137                "suppressed file should not appear in unused_files"
1138            );
1139        }
1140    }
1141}