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