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