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