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