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