1mod boundary;
2mod package_json_utils;
3mod predicates;
4mod unused_deps;
5mod unused_exports;
6mod unused_files;
7mod unused_members;
8
9use rustc_hash::FxHashMap;
10
11use fallow_config::{PackageJson, ResolvedConfig, Severity};
12
13use crate::discover::FileId;
14use crate::extract::ModuleInfo;
15use crate::graph::ModuleGraph;
16use crate::resolve::ResolvedModule;
17use crate::results::{AnalysisResults, CircularDependency};
18use crate::suppress::{self, IssueKind, Suppression};
19
20use unused_deps::{
21 find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
22 find_unresolved_imports, find_unused_dependencies,
23};
24use unused_exports::{collect_export_usages, find_duplicate_exports, find_unused_exports};
25use unused_files::find_unused_files;
26use unused_members::find_unused_members;
27
28pub(crate) type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
31
32pub(crate) fn byte_offset_to_line_col(
35 line_offsets_map: &LineOffsetsMap<'_>,
36 file_id: FileId,
37 byte_offset: u32,
38) -> (u32, u32) {
39 line_offsets_map
40 .get(&file_id)
41 .map_or((1, byte_offset), |offsets| {
42 fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
43 })
44}
45
46fn read_source(path: &std::path::Path) -> String {
50 std::fs::read_to_string(path).unwrap_or_default()
51}
52
53fn is_cross_package_cycle(
58 files: &[std::path::PathBuf],
59 workspaces: &[fallow_config::WorkspaceInfo],
60) -> bool {
61 let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
62 workspaces
63 .iter()
64 .map(|w| w.root.as_path())
65 .filter(|root| path.starts_with(root))
66 .max_by_key(|root| root.components().count())
67 };
68
69 let mut seen_workspace: Option<&std::path::Path> = None;
70 for file in files {
71 if let Some(ws) = find_workspace(file) {
72 match &seen_workspace {
73 None => seen_workspace = Some(ws),
74 Some(prev) if *prev != ws => return true,
75 _ => {}
76 }
77 }
78 }
79 false
80}
81
82#[must_use]
84pub fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
85 find_dead_code_with_resolved(graph, config, &[], None)
86}
87
88#[must_use]
90pub fn find_dead_code_with_resolved(
91 graph: &ModuleGraph,
92 config: &ResolvedConfig,
93 resolved_modules: &[ResolvedModule],
94 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
95) -> AnalysisResults {
96 find_dead_code_full(
97 graph,
98 config,
99 resolved_modules,
100 plugin_result,
101 &[],
102 &[],
103 false,
104 )
105}
106
107#[expect(
109 clippy::too_many_lines,
110 reason = "orchestration function calling all detectors; split candidate for sig-audit-loop"
111)]
112pub fn find_dead_code_full(
113 graph: &ModuleGraph,
114 config: &ResolvedConfig,
115 resolved_modules: &[ResolvedModule],
116 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
117 workspaces: &[fallow_config::WorkspaceInfo],
118 modules: &[ModuleInfo],
119 collect_usages: bool,
120) -> AnalysisResults {
121 let _span = tracing::info_span!("find_dead_code").entered();
122
123 let suppressions_by_file: FxHashMap<FileId, &[Suppression]> = modules
125 .iter()
126 .filter(|m| !m.suppressions.is_empty())
127 .map(|m| (m.file_id, m.suppressions.as_slice()))
128 .collect();
129
130 let line_offsets_by_file: LineOffsetsMap<'_> = modules
133 .iter()
134 .filter(|m| !m.line_offsets.is_empty())
135 .map(|m| (m.file_id, m.line_offsets.as_slice()))
136 .collect();
137
138 let mut results = AnalysisResults::default();
139
140 if config.rules.unused_files != Severity::Off {
141 results.unused_files = find_unused_files(graph, &suppressions_by_file);
142 }
143
144 if config.rules.unused_exports != Severity::Off || config.rules.unused_types != Severity::Off {
145 let (exports, types) = find_unused_exports(
146 graph,
147 config,
148 plugin_result,
149 &suppressions_by_file,
150 &line_offsets_by_file,
151 );
152 if config.rules.unused_exports != Severity::Off {
153 results.unused_exports = exports;
154 }
155 if config.rules.unused_types != Severity::Off {
156 results.unused_types = types;
157 }
158 }
159
160 if config.rules.unused_enum_members != Severity::Off
161 || config.rules.unused_class_members != Severity::Off
162 {
163 let (enum_members, class_members) = find_unused_members(
164 graph,
165 resolved_modules,
166 &suppressions_by_file,
167 &line_offsets_by_file,
168 );
169 if config.rules.unused_enum_members != Severity::Off {
170 results.unused_enum_members = enum_members;
171 }
172 if config.rules.unused_class_members != Severity::Off {
173 results.unused_class_members = class_members;
174 }
175 }
176
177 let pkg_path = config.root.join("package.json");
179 let pkg = PackageJson::load(&pkg_path).ok();
180 if let Some(ref pkg) = pkg {
181 if config.rules.unused_dependencies != Severity::Off
182 || config.rules.unused_dev_dependencies != Severity::Off
183 || config.rules.unused_optional_dependencies != Severity::Off
184 {
185 let (deps, dev_deps, optional_deps) =
186 find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
187 if config.rules.unused_dependencies != Severity::Off {
188 results.unused_dependencies = deps;
189 }
190 if config.rules.unused_dev_dependencies != Severity::Off {
191 results.unused_dev_dependencies = dev_deps;
192 }
193 if config.rules.unused_optional_dependencies != Severity::Off {
194 results.unused_optional_dependencies = optional_deps;
195 }
196 }
197
198 if config.rules.unlisted_dependencies != Severity::Off {
199 results.unlisted_dependencies = find_unlisted_dependencies(
200 graph,
201 pkg,
202 config,
203 workspaces,
204 plugin_result,
205 resolved_modules,
206 &line_offsets_by_file,
207 );
208 }
209 }
210
211 if config.rules.unresolved_imports != Severity::Off && !resolved_modules.is_empty() {
212 let virtual_prefixes: Vec<&str> = plugin_result
213 .map(|pr| {
214 pr.virtual_module_prefixes
215 .iter()
216 .map(String::as_str)
217 .collect()
218 })
219 .unwrap_or_default();
220 let generated_patterns: Vec<&str> = plugin_result
221 .map(|pr| {
222 pr.generated_import_patterns
223 .iter()
224 .map(String::as_str)
225 .collect()
226 })
227 .unwrap_or_default();
228 results.unresolved_imports = find_unresolved_imports(
229 resolved_modules,
230 config,
231 &suppressions_by_file,
232 &virtual_prefixes,
233 &generated_patterns,
234 &line_offsets_by_file,
235 );
236 }
237
238 if config.rules.duplicate_exports != Severity::Off {
239 results.duplicate_exports =
240 find_duplicate_exports(graph, &suppressions_by_file, &line_offsets_by_file);
241 }
242
243 if config.production
245 && let Some(ref pkg) = pkg
246 {
247 results.type_only_dependencies =
248 find_type_only_dependencies(graph, pkg, config, workspaces);
249 }
250
251 if !config.production
253 && config.rules.test_only_dependencies != Severity::Off
254 && let Some(ref pkg) = pkg
255 {
256 results.test_only_dependencies =
257 find_test_only_dependencies(graph, pkg, config, workspaces);
258 }
259
260 if config.rules.boundary_violation != Severity::Off && !config.boundaries.is_empty() {
262 results.boundary_violations = boundary::find_boundary_violations(
263 graph,
264 config,
265 &suppressions_by_file,
266 &line_offsets_by_file,
267 );
268 }
269
270 if config.rules.circular_dependencies != Severity::Off {
272 let cycles = graph.find_cycles();
273 results.circular_dependencies = cycles
274 .into_iter()
275 .filter(|cycle| {
276 !cycle.iter().any(|&id| {
278 suppressions_by_file.get(&id).is_some_and(|supps| {
279 suppress::is_file_suppressed(supps, IssueKind::CircularDependency)
280 })
281 })
282 })
283 .map(|cycle| {
284 let files: Vec<std::path::PathBuf> = cycle
285 .iter()
286 .map(|&id| graph.modules[id.0 as usize].path.clone())
287 .collect();
288 let length = files.len();
289 let (line, col) = if cycle.len() >= 2 {
291 graph
292 .find_import_span_start(cycle[0], cycle[1])
293 .map_or((1, 0), |span_start| {
294 byte_offset_to_line_col(&line_offsets_by_file, cycle[0], span_start)
295 })
296 } else {
297 (1, 0)
298 };
299 CircularDependency {
300 files,
301 length,
302 line,
303 col,
304 is_cross_package: false,
305 }
306 })
307 .collect();
308
309 if !workspaces.is_empty() {
311 for dep in &mut results.circular_dependencies {
312 dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
313 }
314 }
315 }
316
317 if collect_usages {
320 results.export_usages = collect_export_usages(graph, &line_offsets_by_file);
321 }
322
323 if !config.public_packages.is_empty() && !workspaces.is_empty() {
326 let public_roots: Vec<&std::path::Path> = workspaces
327 .iter()
328 .filter(|ws| {
329 config.public_packages.iter().any(|pattern| {
330 ws.name == *pattern
331 || globset::Glob::new(pattern)
332 .ok()
333 .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
334 })
335 })
336 .map(|ws| ws.root.as_path())
337 .collect();
338
339 if !public_roots.is_empty() {
340 results
341 .unused_exports
342 .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
343 results
344 .unused_types
345 .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
346 }
347 }
348
349 results.sort();
353
354 results
355}
356
357#[cfg(test)]
358mod tests {
359 use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
360
361 fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
363 let offsets = compute_line_offsets(source);
364 byte_offset_to_line_col(&offsets, byte_offset)
365 }
366
367 #[test]
370 fn compute_offsets_empty() {
371 assert_eq!(compute_line_offsets(""), vec![0]);
372 }
373
374 #[test]
375 fn compute_offsets_single_line() {
376 assert_eq!(compute_line_offsets("hello"), vec![0]);
377 }
378
379 #[test]
380 fn compute_offsets_multiline() {
381 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
382 }
383
384 #[test]
385 fn compute_offsets_trailing_newline() {
386 assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
387 }
388
389 #[test]
390 fn compute_offsets_crlf() {
391 assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
392 }
393
394 #[test]
395 fn compute_offsets_consecutive_newlines() {
396 assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
397 }
398
399 #[test]
402 fn byte_offset_empty_source() {
403 assert_eq!(line_col("", 0), (1, 0));
404 }
405
406 #[test]
407 fn byte_offset_single_line_start() {
408 assert_eq!(line_col("hello", 0), (1, 0));
409 }
410
411 #[test]
412 fn byte_offset_single_line_middle() {
413 assert_eq!(line_col("hello", 4), (1, 4));
414 }
415
416 #[test]
417 fn byte_offset_multiline_start_of_line2() {
418 assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
419 }
420
421 #[test]
422 fn byte_offset_multiline_middle_of_line3() {
423 assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
424 }
425
426 #[test]
427 fn byte_offset_at_newline_boundary() {
428 assert_eq!(line_col("line1\nline2", 5), (1, 5));
429 }
430
431 #[test]
432 fn byte_offset_multibyte_utf8() {
433 let source = "hi\n\u{1F600}x";
434 assert_eq!(line_col(source, 3), (2, 0));
435 assert_eq!(line_col(source, 7), (2, 4));
436 }
437
438 #[test]
439 fn byte_offset_multibyte_accented_chars() {
440 let source = "caf\u{00E9}\nbar";
441 assert_eq!(line_col(source, 6), (2, 0));
442 assert_eq!(line_col(source, 3), (1, 3));
443 }
444
445 #[test]
446 fn byte_offset_via_map_fallback() {
447 use super::*;
448 let map: LineOffsetsMap<'_> = FxHashMap::default();
449 assert_eq!(
450 super::byte_offset_to_line_col(&map, FileId(99), 42),
451 (1, 42)
452 );
453 }
454
455 #[test]
456 fn byte_offset_via_map_lookup() {
457 use super::*;
458 let offsets = compute_line_offsets("abc\ndef\nghi");
459 let mut map: LineOffsetsMap<'_> = FxHashMap::default();
460 map.insert(FileId(0), &offsets);
461 assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
462 }
463
464 mod orchestration {
467 use super::super::*;
468 use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
469 use std::path::PathBuf;
470
471 fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
472 FallowConfig {
473 rules,
474 ..Default::default()
475 }
476 .resolve(
477 PathBuf::from("/tmp/orchestration-test"),
478 OutputFormat::Human,
479 1,
480 true,
481 true,
482 )
483 }
484
485 #[test]
486 fn find_dead_code_all_rules_off_returns_empty() {
487 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
488 use crate::graph::ModuleGraph;
489 use crate::resolve::ResolvedModule;
490 use rustc_hash::FxHashSet;
491
492 let files = vec![DiscoveredFile {
493 id: FileId(0),
494 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
495 size_bytes: 100,
496 }];
497 let entry_points = vec![EntryPoint {
498 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
499 source: EntryPointSource::ManualEntry,
500 }];
501 let resolved = vec![ResolvedModule {
502 file_id: FileId(0),
503 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
504 exports: vec![],
505 re_exports: vec![],
506 resolved_imports: vec![],
507 resolved_dynamic_imports: vec![],
508 resolved_dynamic_patterns: vec![],
509 member_accesses: vec![],
510 whole_object_uses: vec![],
511 has_cjs_exports: false,
512 unused_import_bindings: FxHashSet::default(),
513 }];
514 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
515
516 let rules = RulesConfig {
517 unused_files: Severity::Off,
518 unused_exports: Severity::Off,
519 unused_types: Severity::Off,
520 unused_dependencies: Severity::Off,
521 unused_dev_dependencies: Severity::Off,
522 unused_optional_dependencies: Severity::Off,
523 unused_enum_members: Severity::Off,
524 unused_class_members: Severity::Off,
525 unresolved_imports: Severity::Off,
526 unlisted_dependencies: Severity::Off,
527 duplicate_exports: Severity::Off,
528 type_only_dependencies: Severity::Off,
529 circular_dependencies: Severity::Off,
530 test_only_dependencies: Severity::Off,
531 boundary_violation: Severity::Off,
532 coverage_gaps: Severity::Off,
533 };
534 let config = make_config_with_rules(rules);
535 let results = find_dead_code(&graph, &config);
536
537 assert!(results.unused_files.is_empty());
538 assert!(results.unused_exports.is_empty());
539 assert!(results.unused_types.is_empty());
540 assert!(results.unused_dependencies.is_empty());
541 assert!(results.unused_dev_dependencies.is_empty());
542 assert!(results.unused_optional_dependencies.is_empty());
543 assert!(results.unused_enum_members.is_empty());
544 assert!(results.unused_class_members.is_empty());
545 assert!(results.unresolved_imports.is_empty());
546 assert!(results.unlisted_dependencies.is_empty());
547 assert!(results.duplicate_exports.is_empty());
548 assert!(results.circular_dependencies.is_empty());
549 assert!(results.export_usages.is_empty());
550 }
551
552 #[test]
553 fn find_dead_code_full_collect_usages_flag() {
554 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
555 use crate::extract::ExportName;
556 use crate::graph::{ExportSymbol, ModuleGraph};
557 use crate::resolve::ResolvedModule;
558 use oxc_span::Span;
559 use rustc_hash::FxHashSet;
560
561 let files = vec![DiscoveredFile {
562 id: FileId(0),
563 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
564 size_bytes: 100,
565 }];
566 let entry_points = vec![EntryPoint {
567 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
568 source: EntryPointSource::ManualEntry,
569 }];
570 let resolved = vec![ResolvedModule {
571 file_id: FileId(0),
572 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
573 exports: vec![],
574 re_exports: vec![],
575 resolved_imports: vec![],
576 resolved_dynamic_imports: vec![],
577 resolved_dynamic_patterns: vec![],
578 member_accesses: vec![],
579 whole_object_uses: vec![],
580 has_cjs_exports: false,
581 unused_import_bindings: FxHashSet::default(),
582 }];
583 let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
584 graph.modules[0].exports = vec![ExportSymbol {
585 name: ExportName::Named("myExport".to_string()),
586 is_type_only: false,
587 is_public: false,
588 span: Span::new(10, 30),
589 references: vec![],
590 members: vec![],
591 }];
592
593 let rules = RulesConfig::default();
594 let config = make_config_with_rules(rules);
595
596 let results_no_collect = find_dead_code_full(
598 &graph,
599 &config,
600 &[],
601 None,
602 &[],
603 &[],
604 false, );
606 assert!(
607 results_no_collect.export_usages.is_empty(),
608 "export_usages should be empty when collect_usages is false"
609 );
610
611 let results_with_collect = find_dead_code_full(
613 &graph,
614 &config,
615 &[],
616 None,
617 &[],
618 &[],
619 true, );
621 assert!(
622 !results_with_collect.export_usages.is_empty(),
623 "export_usages should be populated when collect_usages is true"
624 );
625 assert_eq!(
626 results_with_collect.export_usages[0].export_name,
627 "myExport"
628 );
629 }
630
631 #[test]
632 fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
633 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
634 use crate::graph::ModuleGraph;
635 use crate::resolve::ResolvedModule;
636 use rustc_hash::FxHashSet;
637
638 let files = vec![DiscoveredFile {
639 id: FileId(0),
640 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
641 size_bytes: 100,
642 }];
643 let entry_points = vec![EntryPoint {
644 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
645 source: EntryPointSource::ManualEntry,
646 }];
647 let resolved = vec![ResolvedModule {
648 file_id: FileId(0),
649 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
650 exports: vec![],
651 re_exports: vec![],
652 resolved_imports: vec![],
653 resolved_dynamic_imports: vec![],
654 resolved_dynamic_patterns: vec![],
655 member_accesses: vec![],
656 whole_object_uses: vec![],
657 has_cjs_exports: false,
658 unused_import_bindings: FxHashSet::default(),
659 }];
660 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
661 let config = make_config_with_rules(RulesConfig::default());
662
663 let results = find_dead_code(&graph, &config);
665 assert!(results.unused_exports.is_empty());
667 }
668
669 #[test]
670 fn suppressions_built_from_modules() {
671 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
672 use crate::extract::ModuleInfo;
673 use crate::graph::ModuleGraph;
674 use crate::resolve::ResolvedModule;
675 use crate::suppress::{IssueKind, Suppression};
676 use rustc_hash::FxHashSet;
677
678 let files = vec![
679 DiscoveredFile {
680 id: FileId(0),
681 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
682 size_bytes: 100,
683 },
684 DiscoveredFile {
685 id: FileId(1),
686 path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
687 size_bytes: 100,
688 },
689 ];
690 let entry_points = vec![EntryPoint {
691 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
692 source: EntryPointSource::ManualEntry,
693 }];
694 let resolved = files
695 .iter()
696 .map(|f| ResolvedModule {
697 file_id: f.id,
698 path: f.path.clone(),
699 exports: vec![],
700 re_exports: vec![],
701 resolved_imports: vec![],
702 resolved_dynamic_imports: vec![],
703 resolved_dynamic_patterns: vec![],
704 member_accesses: vec![],
705 whole_object_uses: vec![],
706 has_cjs_exports: false,
707 unused_import_bindings: FxHashSet::default(),
708 })
709 .collect::<Vec<_>>();
710 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
711
712 let modules = vec![ModuleInfo {
714 file_id: FileId(1),
715 exports: vec![],
716 imports: vec![],
717 re_exports: vec![],
718 dynamic_imports: vec![],
719 dynamic_import_patterns: vec![],
720 require_calls: vec![],
721 member_accesses: vec![],
722 whole_object_uses: vec![],
723 has_cjs_exports: false,
724 content_hash: 0,
725 suppressions: vec![Suppression {
726 line: 0,
727 kind: Some(IssueKind::UnusedFile),
728 }],
729 unused_import_bindings: vec![],
730 line_offsets: vec![],
731 complexity: vec![],
732 }];
733
734 let rules = RulesConfig {
735 unused_files: Severity::Error,
736 ..RulesConfig::default()
737 };
738 let config = make_config_with_rules(rules);
739
740 let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
741
742 assert!(
747 !results
748 .unused_files
749 .iter()
750 .any(|f| f.path.to_string_lossy().contains("utils.ts")),
751 "suppressed file should not appear in unused_files"
752 );
753 }
754 }
755}