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