1use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, VecDeque};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::{Instant, UNIX_EPOCH};
5
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9
10use crate::cache_freshness::{self, FileFreshness};
11use crate::callgraph::{resolve_module_path, resolve_reexported_symbol_target};
12use crate::calls::extract_type_references;
13use crate::imports::{parse_imports, specifier_imported_name, specifier_local_name};
14use crate::inspect::job::{
15 canonicalize_normalized, dead_code_skipped_language, is_test_file, is_test_support_file,
16 language_name, CALLGRAPH_PROVENANCE_REEXPORT, CALLGRAPH_PROVENANCE_TREESITTER,
17 DISPATCHED_CALLEE_SEPARATOR,
18};
19use crate::inspect::oxc_engine::{
20 analyze_file_facts, AnalyzeOptions, DynamicImportFact, ExportFact, FileFacts, FileId,
21 ImportFact, LivenessVerdict, OxcEngineResult, OxcFileVerdicts, OxcReExportContext,
22 ReExportFact, ReExportKind, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
23};
24use crate::inspect::{
25 CallgraphOutboundCall, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
26 InspectResult, InspectScanSuccess,
27};
28use crate::parser::{detect_language, grammar_for, LangId};
29
30use super::DEFAULT_EXPORT_MARKER_KIND;
31
32const MAX_DRILL_DOWN_ITEMS: usize = 100;
33pub(crate) const DEAD_CODE_FACTS_FORMAT_VERSION: u32 = 4;
34const MACRO_TOKEN_LIVENESS_PROVENANCE: &str = "macro_token_liveness";
35const RUST_MACRO_REF_SHAPE_CALL: &str = "call";
36const RUST_MACRO_REF_SHAPE_METHOD: &str = "method";
37const RUST_MACRO_REF_SHAPE_STRUCT: &str = "struct";
38const TOP_LEVEL_SYMBOL: &str = "<top-level>";
39
40type ExportNode = (String, String);
41type OutboundCallsByCallerFile<'a> = BTreeMap<PathBuf, Vec<&'a CallgraphOutboundCall>>;
42type MethodNamesByLanguage = BTreeMap<String, BTreeSet<String>>;
43
44#[derive(Debug, Default)]
45struct ImportedExportLiveness {
46 root_exports: Vec<ImportedExportContribution>,
47 namespace_exports: Vec<ImportedExportContribution>,
48}
49
50#[derive(Debug, Default)]
51struct FileAnalysis {
52 raw_imports: Vec<RawImportContribution>,
53 rust_imports: Vec<RawImportContribution>,
54 raw_reexports: Vec<RawReexportContribution>,
55 attribute_entry_points: Vec<String>,
56 macro_token_refs: Vec<MacroTokenRefContribution>,
57 cfg_test_ranges: Vec<RustCfgTestRange>,
58 type_ref_names: BTreeSet<String>,
59}
60
61#[derive(Debug, Clone)]
62struct RustMacroToken<'a> {
63 text: &'a str,
64 kind: &'a str,
65 line: u32,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69struct RustCfgTestRange {
70 start_line: u32,
71 end_line: u32,
72}
73
74impl RustCfgTestRange {
75 fn contains(self, line: u32) -> bool {
76 self.start_line <= line && line <= self.end_line
77 }
78}
79
80#[derive(Debug, Clone)]
81struct RustImportedSymbolSpec {
82 local_name: String,
83 module_segments: Vec<String>,
84 imported_name: String,
85}
86
87#[derive(Default)]
88struct DeadCodeFileAnalyzer {
89 parsers: HashMap<LangId, tree_sitter::Parser>,
90}
91
92#[derive(Debug, Serialize)]
93struct OxcDeadCodeFactsPayload<'a> {
94 format_version: u32,
95 content_hash: &'a str,
96 exports: &'a [ExportFact],
97 imports: &'a [ImportFact],
98 re_exports: &'a [ReExportFact],
99 dynamic_imports: &'a [DynamicImportFact],
100 same_file_value_references: &'a BTreeSet<String>,
101 used_import_bindings: &'a BTreeSet<String>,
102 type_referenced_import_bindings: &'a BTreeSet<String>,
103 value_referenced_import_bindings: &'a BTreeSet<String>,
104 parse_error: &'a Option<String>,
105}
106
107impl DeadCodeFileAnalyzer {
108 fn analyze_file(&mut self, file: &Path, has_oxc_file: bool) -> FileAnalysis {
109 let Some(lang) = detect_language(file) else {
110 return FileAnalysis::default();
111 };
112 let needs_type_refs = supports_type_refs(lang);
113 let is_ts_js = matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript);
114 let needs_ts_raw_facts = is_ts_js && !has_oxc_file;
117 let needs_rust_reexports = matches!(lang, LangId::Rust);
118 let needs_rust_attribute_entry_points = matches!(lang, LangId::Rust);
119 let needs_rust_macro_token_refs = matches!(lang, LangId::Rust);
120
121 if !needs_type_refs
122 && !needs_ts_raw_facts
123 && !needs_rust_reexports
124 && !needs_rust_attribute_entry_points
125 && !needs_rust_macro_token_refs
126 {
127 return FileAnalysis::default();
128 }
129
130 let Ok(source) = fs::read_to_string(file) else {
131 return FileAnalysis::default();
132 };
133 let needs_tree = needs_type_refs
134 || needs_ts_raw_facts
135 || needs_rust_attribute_entry_points
136 || needs_rust_macro_token_refs;
137 let tree = needs_tree
138 .then(|| self.parse_source(lang, &source))
139 .flatten();
140
141 let type_ref_names = if needs_type_refs {
142 tree.as_ref()
143 .map(|tree| extract_type_references(&source, tree.root_node(), lang))
144 .unwrap_or_default()
145 } else {
146 BTreeSet::new()
147 };
148
149 let raw_imports = if needs_ts_raw_facts {
150 tree.as_ref()
151 .map(|tree| raw_imports_from_tree(&source, tree, lang))
152 .unwrap_or_default()
153 } else {
154 Vec::new()
155 };
156
157 let rust_imports = if needs_rust_macro_token_refs {
158 tree.as_ref()
159 .map(|tree| rust_raw_import_contributions(&source, tree))
160 .unwrap_or_default()
161 } else {
162 Vec::new()
163 };
164
165 let raw_reexports = if needs_ts_raw_facts {
166 tree.as_ref()
167 .map(|tree| ts_raw_reexport_contributions(&source, tree.root_node()))
168 .unwrap_or_default()
169 } else if needs_rust_reexports {
170 rust_raw_reexport_contributions(&source)
171 } else {
172 Vec::new()
173 };
174
175 let attribute_entry_points = if needs_rust_attribute_entry_points {
176 tree.as_ref()
177 .map(|tree| {
178 let mut roots = BTreeSet::new();
179 for entry in
180 crate::parser::rust_attribute_entry_points(&source, tree.root_node())
181 {
182 roots.insert(entry.name);
183 roots.insert(entry.scoped_name);
184 }
185 roots.into_iter().collect()
186 })
187 .unwrap_or_default()
188 } else {
189 Vec::new()
190 };
191
192 let macro_token_refs = if needs_rust_macro_token_refs {
193 tree.as_ref()
194 .map(|tree| rust_macro_token_refs(&source, tree.root_node()))
195 .unwrap_or_default()
196 } else {
197 Vec::new()
198 };
199 let cfg_test_ranges = if lang == LangId::Rust {
200 tree.as_ref()
201 .map(|tree| rust_cfg_test_ranges(&source, tree.root_node()))
202 .unwrap_or_default()
203 } else {
204 Vec::new()
205 };
206
207 FileAnalysis {
208 raw_imports,
209 rust_imports,
210 raw_reexports,
211 attribute_entry_points,
212 macro_token_refs,
213 cfg_test_ranges,
214 type_ref_names,
215 }
216 }
217
218 fn parse_source(&mut self, lang: LangId, source: &str) -> Option<tree_sitter::Tree> {
219 let parser = match self.parsers.entry(lang) {
220 Entry::Occupied(entry) => entry.into_mut(),
221 Entry::Vacant(entry) => {
222 let grammar = grammar_for(lang);
223 let mut parser = tree_sitter::Parser::new();
224 if parser.set_language(&grammar).is_err() {
225 return None;
226 }
227 entry.insert(parser)
228 }
229 };
230
231 parser.parse(source, None)
232 }
233}
234
235pub fn run_dead_code_scan(job: &InspectJob) -> InspectResult {
236 run_dead_code_scan_with_oxc_started(job, None, Instant::now())
237}
238
239pub(crate) fn run_dead_code_scan_with_oxc(
240 job: &InspectJob,
241 oxc_result: Option<&OxcEngineResult>,
242) -> InspectResult {
243 run_dead_code_scan_with_oxc_started(job, oxc_result, Instant::now())
244}
245
246fn run_dead_code_scan_with_oxc_started(
247 job: &InspectJob,
248 oxc_result: Option<&OxcEngineResult>,
249 started: Instant,
250) -> InspectResult {
251 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
252 let success = InspectScanSuccess {
253 scanned_files: job.scope_files.clone(),
254 contributions: Vec::new(),
255 aggregate: callgraph_unavailable_aggregate(job.scope_files.len()),
256 };
257 return InspectResult::success(job, success, started.elapsed());
258 };
259
260 let fallback_exports_by_file = fallback_export_contributions_by_file(job, snapshot);
261 let oxc_facts_by_file = oxc_result
262 .map(|result| {
263 result
264 .facts
265 .iter()
266 .cloned()
267 .map(|facts| (relative_path(&job.project_root, &facts.path), facts))
268 .collect::<BTreeMap<_, _>>()
269 })
270 .unwrap_or_default();
271 let oxc_parse_errors_by_file = oxc_result
272 .map(|result| {
273 result.errors.iter().fold(
274 BTreeMap::<String, Vec<String>>::new(),
275 |mut errors, error| {
276 errors
277 .entry(relative_path(&job.project_root, &error.file))
278 .or_default()
279 .push(error.message.clone());
280 errors
281 },
282 )
283 })
284 .unwrap_or_default();
285 let oxc_skipped_files = oxc_result
286 .map(|result| oxc_skipped_files_payload(&job.project_root, result))
287 .unwrap_or_default();
288
289 let contributions = job
290 .scope_files
291 .par_iter()
292 .map_init(DeadCodeFileAnalyzer::default, |file_analyzer, file| {
293 gather_file_contribution(
294 job,
295 file,
296 &fallback_exports_by_file,
297 &oxc_facts_by_file,
298 &oxc_parse_errors_by_file,
299 &oxc_skipped_files,
300 file_analyzer,
301 )
302 })
303 .collect::<Vec<_>>();
304
305 let public_api_files = collect_public_api_files(&job.project_root);
306 let roles = crate::inspect::entry_points::resolve_project_roles(&job.project_root);
307 let aggregate = aggregate_dead_code_contributions_with_snapshot(
308 &job.project_root,
309 snapshot,
310 &contributions,
311 &public_api_files,
312 &roles,
313 Some(MAX_DRILL_DOWN_ITEMS),
314 );
315 let success = InspectScanSuccess {
316 scanned_files: job.scope_files.clone(),
317 contributions,
318 aggregate,
319 };
320
321 InspectResult::success(job, success, started.elapsed())
322}
323
324fn fallback_export_contributions_by_file(
325 job: &InspectJob,
326 snapshot: &CallgraphSnapshot,
327) -> BTreeMap<String, Vec<ExportContribution>> {
328 let mut by_file: BTreeMap<String, Vec<ExportContribution>> = BTreeMap::new();
329 for export in &snapshot.exported_symbols {
330 if export.kind == DEFAULT_EXPORT_MARKER_KIND {
331 continue;
332 }
333 by_file
334 .entry(relative_path(&job.project_root, &export.file))
335 .or_default()
336 .push(ExportContribution {
337 symbol: export.symbol.clone(),
338 kind: export.kind.clone(),
339 line: export.line,
340 is_type_like: is_type_like_kind(&export.kind),
341 is_entry_point: false,
342 has_references: false,
343 test_only_reference_files: Vec::new(),
344 verdict: None,
345 reason: None,
346 provenance: None,
347 also_reexported: Vec::new(),
348 });
349 }
350 by_file
351}
352
353fn group_outbound_calls_by_caller_file<'a>(
354 project_root: &Path,
355 outbound_calls: &'a [CallgraphOutboundCall],
356) -> OutboundCallsByCallerFile<'a> {
357 let mut by_file: OutboundCallsByCallerFile<'a> = BTreeMap::new();
358 for call in outbound_calls {
359 by_file
360 .entry(normalize_absolute(project_root, &call.caller_file))
361 .or_default()
362 .push(call);
363 }
364 by_file
365}
366
367fn gather_file_contribution(
368 job: &InspectJob,
369 file: &Path,
370 fallback_exports_by_file: &BTreeMap<String, Vec<ExportContribution>>,
371 oxc_facts_by_file: &BTreeMap<String, FileFacts>,
372 oxc_parse_errors_by_file: &BTreeMap<String, Vec<String>>,
373 oxc_skipped_files: &[Value],
374 file_analyzer: &mut DeadCodeFileAnalyzer,
375) -> FileContribution {
376 let file_name = relative_path(&job.project_root, file);
377 let generated = crate::inspect::generated::is_generated_file(&job.project_root, file);
378 if let Some(language) = dead_code_skipped_language(file) {
379 return FileContribution::new(
380 InspectCategory::DeadCode,
381 file.to_path_buf(),
382 collect_freshness(file),
383 json!({
384 "file": file_name,
385 "facts_format_version": DEAD_CODE_FACTS_FORMAT_VERSION,
386 "generated": generated,
387 "exports": [],
388 "skipped_languages": [language],
389 }),
390 );
391 }
392
393 let oxc_facts = oxc_facts_by_file.get(&file_name);
394 let exports = oxc_facts
395 .map(oxc_fact_export_contributions)
396 .unwrap_or_else(|| {
397 fallback_exports_by_file
398 .get(&file_name)
399 .cloned()
400 .unwrap_or_default()
401 });
402 let FileAnalysis {
403 raw_imports,
404 rust_imports,
405 raw_reexports,
406 attribute_entry_points,
407 macro_token_refs,
408 cfg_test_ranges,
409 type_ref_names,
410 } = file_analyzer.analyze_file(file, oxc_facts.is_some());
411
412 let mut payload = json!({
413 "file": file_name,
414 "facts_format_version": DEAD_CODE_FACTS_FORMAT_VERSION,
415 "generated": generated,
416 "exports": exports
417 .iter()
418 .map(|export| {
419 let mut value = json!({
420 "symbol": export.symbol,
421 "kind": export.kind,
422 "line": export.line,
423 });
424 if export.is_type_like {
425 value["is_type_like"] = json!(true);
426 }
427 value
428 })
429 .collect::<Vec<_>>(),
430 });
431
432 if !raw_imports.is_empty() {
433 payload["raw_imports"] = json!(raw_imports);
434 }
435 if !raw_reexports.is_empty() {
436 payload["raw_reexports"] = json!(raw_reexports);
437 }
438 if !rust_imports.is_empty() {
439 payload["rust_imports"] = json!(rust_imports);
440 }
441 if !macro_token_refs.is_empty() {
442 payload["macro_token_refs"] = json!(macro_token_refs);
443 }
444 if !attribute_entry_points.is_empty() {
445 payload["attribute_entry_points"] = json!(attribute_entry_points);
446 }
447 if !cfg_test_ranges.is_empty() {
448 payload["cfg_test_ranges"] = json!(cfg_test_ranges);
449 }
450 if let Some(facts) = oxc_facts {
451 payload["provenance"] = json!(OXC_PROVENANCE);
452 payload["oxc_facts"] = json!(OxcDeadCodeFactsPayload {
453 format_version: FACTS_FORMAT_VERSION,
454 content_hash: &facts.content_hash,
455 exports: &facts.exports,
456 imports: &facts.imports,
457 re_exports: &facts.re_exports,
458 dynamic_imports: &facts.dynamic_imports,
459 same_file_value_references: &facts.same_file_value_references,
460 used_import_bindings: &facts.used_import_bindings,
461 type_referenced_import_bindings: &facts.type_referenced_import_bindings,
462 value_referenced_import_bindings: &facts.value_referenced_import_bindings,
463 parse_error: &facts.parse_error,
464 });
465 }
466 if let Some(parse_errors) = oxc_parse_errors_by_file.get(&file_name) {
467 payload["parse_errors"] = json!(parse_errors
468 .iter()
469 .map(|message| json!({
470 "file": file_name,
471 "message": message,
472 }))
473 .collect::<Vec<_>>());
474 }
475 if oxc_facts.is_some() && !oxc_skipped_files.is_empty() {
476 payload["skipped_files"] = Value::Array(oxc_skipped_files.to_vec());
477 }
478
479 FileContribution::new(
480 InspectCategory::DeadCode,
481 file.to_path_buf(),
482 collect_freshness(file),
483 payload,
484 )
485 .with_type_ref_names(type_ref_names)
486}
487
488fn oxc_fact_export_contributions(facts: &FileFacts) -> Vec<ExportContribution> {
489 facts
490 .exports
491 .iter()
492 .map(|export| ExportContribution {
493 symbol: export.name.as_symbol(),
494 kind: export.kind.clone(),
495 line: export.line,
496 is_type_like: export.is_type_only || is_type_like_kind(&export.kind),
497 is_entry_point: false,
498 has_references: false,
499 test_only_reference_files: Vec::new(),
500 verdict: None,
501 reason: None,
502 provenance: None,
503 also_reexported: Vec::new(),
504 })
505 .collect()
506}
507
508fn oxc_export_contributions(file: &OxcFileVerdicts) -> Vec<ExportContribution> {
509 file.exports
510 .iter()
511 .map(|export| ExportContribution {
512 symbol: export.symbol.clone(),
513 kind: export.kind.clone(),
514 line: export.line,
515 is_type_like: is_type_like_kind(&export.kind),
516 is_entry_point: matches!(export.verdict, LivenessVerdict::Used),
517 has_references: export.has_references,
518 test_only_reference_files: export.test_only_reference_files.clone(),
519 verdict: Some(export.verdict),
520 reason: Some(export.reason.clone()),
521 provenance: Some(export.provenance.clone()),
522 also_reexported: export.also_reexported.clone(),
523 })
524 .collect()
525}
526
527fn oxc_skipped_files_payload(project_root: &Path, oxc_result: &OxcEngineResult) -> Vec<Value> {
528 oxc_result
529 .skipped_outside_root
530 .iter()
531 .map(|path| {
532 json!({
533 "file": relative_path(project_root, path),
534 "reason": "outside_project_root",
535 })
536 })
537 .collect()
538}
539
540pub(crate) fn callgraph_unavailable_aggregate(scanned_files: usize) -> serde_json::Value {
541 json!({
542 "count": 0,
543 "items": [],
544 "by_language": {},
545 "languages_skipped": [],
546 "drill_down_capped": false,
547 "uncertain_count": 0,
548 "uncertain_items": [],
549 "callgraph_available": false,
550 "scanned_files": scanned_files,
551 "notes": ["callgraph_unavailable"],
552 })
553}
554
555pub(crate) fn aggregate_dead_code_contributions_with_snapshot(
556 project_root: &Path,
557 snapshot: &CallgraphSnapshot,
558 contributions: &[FileContribution],
559 public_api_files: &BTreeSet<String>,
560 roles: &crate::inspect::entry_points::ProjectRoles,
561 drill_down_limit: Option<usize>,
562) -> serde_json::Value {
563 let parsed = parse_dead_code_contributions(contributions);
564 let materialized =
565 materialize_dead_code_contributions(project_root, snapshot, parsed, public_api_files);
566 aggregate_materialized_dead_code_contributions(
567 project_root,
568 &materialized,
569 public_api_files,
570 roles,
571 drill_down_limit,
572 contributions.len(),
573 )
574}
575
576fn parse_dead_code_contributions(contributions: &[FileContribution]) -> Vec<DeadCodeContribution> {
577 contributions
578 .iter()
579 .filter_map(|contribution| {
580 serde_json::from_value::<DeadCodeContribution>(contribution.contribution.clone()).ok()
581 })
582 .collect::<Vec<_>>()
583}
584
585fn materialize_dead_code_contributions(
586 project_root: &Path,
587 snapshot: &CallgraphSnapshot,
588 parsed: Vec<DeadCodeContribution>,
589 public_api_files: &BTreeSet<String>,
590) -> Vec<DeadCodeContribution> {
591 let liveness_root_files = snapshot
592 .entry_points
593 .iter()
594 .map(|file| relative_path(project_root, file))
595 .collect::<BTreeSet<_>>();
596 let executable_root_exports_by_file =
597 crate::inspect::entry_points::resolve_entry_points(project_root)
598 .executable_root_exports()
599 .into_iter()
600 .map(|(file, exports)| (relative_path(project_root, &file), exports))
601 .collect::<BTreeMap<_, _>>();
602 let attribute_roots_from_snapshot = snapshot
603 .entry_point_symbols
604 .iter()
605 .map(|(file, symbols)| (relative_path(project_root, file), symbols.clone()))
606 .collect::<BTreeMap<_, _>>();
607 let (exported_symbols_by_file, files_by_exported_symbol, default_export_symbols_by_file) =
608 exported_symbol_indexes_from_contributions(project_root, snapshot, &parsed);
609 let outbound_calls_by_caller_file =
610 group_outbound_calls_by_caller_file(project_root, &snapshot.outbound_calls);
611 let oxc_by_file = oxc_verdicts_by_file(project_root, snapshot, &parsed, public_api_files);
612
613 parsed
614 .into_iter()
615 .map(|mut contribution| {
616 let _facts_format_version = contribution.facts_format_version;
617 let absolute_file = project_root.join(&contribution.file);
618 let normalized_file = normalize_absolute(project_root, &absolute_file);
619 let outbound_calls_for_file = outbound_calls_by_caller_file
620 .get(&normalized_file)
621 .map(Vec::as_slice)
622 .unwrap_or(&[]);
623 let mut exports = oxc_by_file
624 .get(&contribution.file)
625 .map(oxc_export_contributions)
626 .unwrap_or_else(|| contribution.exports.clone());
627
628 let mut internal_calls = outbound_calls_for_file
629 .iter()
630 .copied()
631 .filter_map(|call| {
632 project_internal_call(
633 project_root,
634 call,
635 &contribution.file,
636 is_test_file(&contribution.file)
637 || contribution
638 .cfg_test_ranges
639 .iter()
640 .any(|range| range.contains(call.line)),
641 &exported_symbols_by_file,
642 &files_by_exported_symbol,
643 )
644 })
645 .collect::<Vec<_>>();
646 internal_calls.extend(resolve_raw_reexport_liveness_edges(
647 project_root,
648 &contribution.file,
649 &contribution.raw_reexports,
650 &exported_symbols_by_file,
651 &default_export_symbols_by_file,
652 ));
653 if let Some(oxc_facts) = &contribution.oxc_facts {
654 internal_calls.extend(resolve_oxc_reexport_liveness_edges(
655 project_root,
656 &contribution.file,
657 oxc_facts,
658 &exported_symbols_by_file,
659 &default_export_symbols_by_file,
660 ));
661 }
662 internal_calls.extend(resolve_macro_token_liveness_edges(
663 project_root,
664 &contribution.file,
665 &contribution.macro_token_refs,
666 &contribution.rust_imports,
667 &exported_symbols_by_file,
668 ));
669 sort_dedup_internal_calls(&mut internal_calls);
670
671 let dispatched_method_names = outbound_calls_for_file
672 .iter()
673 .copied()
674 .flat_map(|call| dispatched_method_names_from_call(call, &contribution.file))
675 .collect::<BTreeSet<_>>()
676 .into_iter()
677 .collect::<Vec<_>>();
678 let imported_export_liveness = resolve_raw_imported_export_liveness_roots(
679 project_root,
680 &contribution.file,
681 &contribution.raw_imports,
682 &exported_symbols_by_file,
683 &default_export_symbols_by_file,
684 );
685 let mut attribute_entry_points = contribution
686 .attribute_entry_points
687 .iter()
688 .cloned()
689 .collect::<BTreeSet<_>>();
690 if let Some(snapshot_roots) = attribute_roots_from_snapshot.get(&contribution.file) {
691 attribute_entry_points.extend(snapshot_roots.iter().cloned());
692 }
693 let liveness_roots = liveness_roots_for_file(
694 &contribution.file,
695 &exports,
696 &internal_calls,
697 &attribute_entry_points,
698 executable_root_exports_by_file.get(&contribution.file),
699 liveness_root_files.contains(&contribution.file),
700 public_api_files.contains(&contribution.file),
701 );
702 for export in &mut exports {
703 export.is_entry_point = liveness_roots.contains(&export.symbol);
704 }
705
706 contribution.exports = exports;
707 contribution.internal_calls = internal_calls
708 .into_iter()
709 .map(InternalCallContribution::from)
710 .collect();
711 contribution.liveness_roots = liveness_roots;
712 contribution.imported_exports = imported_export_liveness.root_exports;
713 contribution.namespace_imported_exports = imported_export_liveness.namespace_exports;
714 contribution.dispatched_method_names = dispatched_method_names;
715 contribution
716 })
717 .collect()
718}
719
720fn exported_symbol_indexes_from_contributions(
721 project_root: &Path,
722 snapshot: &CallgraphSnapshot,
723 contributions: &[DeadCodeContribution],
724) -> (
725 BTreeMap<String, BTreeSet<String>>,
726 BTreeMap<String, BTreeSet<String>>,
727 BTreeMap<String, String>,
728) {
729 let mut exported_symbols_by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
730 let mut files_by_exported_symbol: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
731 let mut default_export_symbols_by_file: BTreeMap<String, String> = BTreeMap::new();
732
733 for contribution in contributions {
734 for export in &contribution.exports {
735 exported_symbols_by_file
736 .entry(contribution.file.clone())
737 .or_default()
738 .insert(export.symbol.clone());
739 files_by_exported_symbol
740 .entry(export.symbol.clone())
741 .or_default()
742 .insert(contribution.file.clone());
743 }
744 }
745
746 for export in &snapshot.exported_symbols {
747 let file = relative_path(project_root, &export.file);
748 if export.kind == DEFAULT_EXPORT_MARKER_KIND {
749 default_export_symbols_by_file.insert(file, export.symbol.clone());
750 }
751 }
752
753 (
754 exported_symbols_by_file,
755 files_by_exported_symbol,
756 default_export_symbols_by_file,
757 )
758}
759
760fn oxc_verdicts_by_file(
761 project_root: &Path,
762 snapshot: &CallgraphSnapshot,
763 contributions: &[DeadCodeContribution],
764 public_api_files: &BTreeSet<String>,
765) -> BTreeMap<String, OxcFileVerdicts> {
766 let facts = contributions
767 .iter()
768 .filter_map(|contribution| {
769 let oxc_facts = contribution.oxc_facts.as_ref()?;
770 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
771 return None;
772 }
773 Some(FileFacts {
774 file_id: FileId(0),
775 path: canonical_or_normalized(project_root, &project_root.join(&contribution.file)),
776 content_hash: oxc_facts.content_hash.clone(),
777 exports: oxc_facts.exports.clone(),
778 imports: oxc_facts.imports.clone(),
779 re_exports: oxc_facts.re_exports.clone(),
780 dynamic_imports: oxc_facts.dynamic_imports.clone(),
781 same_file_value_references: oxc_facts.same_file_value_references.clone(),
782 used_import_bindings: oxc_facts.used_import_bindings.clone(),
783 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
784 value_referenced_import_bindings: oxc_facts
785 .value_referenced_import_bindings
786 .clone(),
787 parse_error: oxc_facts.parse_error.clone(),
788 })
789 })
790 .collect::<Vec<_>>();
791 if facts.is_empty() {
792 return BTreeMap::new();
793 }
794
795 let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
796 analyze_file_facts(
797 project_root,
798 facts,
799 AnalyzeOptions {
800 entry_points: snapshot.entry_points.iter().cloned().collect(),
801 public_api_files: public_api_files
802 .iter()
803 .map(|file| project_root.join(file))
804 .collect(),
805 executable_root_exports: entry_points.executable_root_exports(),
806 force_reparse_files: Vec::new(),
807 entry_reachability: true,
808 },
809 Vec::new(),
810 )
811 .files
812 .into_iter()
813 .map(|file| (file.relative_file.clone(), file))
814 .collect()
815}
816
817fn sort_dedup_internal_calls(internal_calls: &mut Vec<InternalCall>) {
818 internal_calls.sort_by(|left, right| {
819 left.caller_symbol
820 .cmp(&right.caller_symbol)
821 .then_with(|| left.file.cmp(&right.file))
822 .then_with(|| left.symbol.cmp(&right.symbol))
823 .then_with(|| left.line.cmp(&right.line))
824 .then_with(|| left.provenance.cmp(&right.provenance))
825 .then_with(|| left.test_origin.cmp(&right.test_origin))
826 });
827 internal_calls.dedup_by(|left, right| {
828 left.caller_symbol == right.caller_symbol
829 && left.file == right.file
830 && left.symbol == right.symbol
831 && left.line == right.line
832 && left.provenance == right.provenance
833 && left.test_origin == right.test_origin
834 });
835}
836
837fn aggregate_materialized_dead_code_contributions(
838 project_root: &Path,
839 parsed: &[DeadCodeContribution],
840 public_api_files: &BTreeSet<String>,
841 roles: &crate::inspect::entry_points::ProjectRoles,
842 drill_down_limit: Option<usize>,
843 scanned_files: usize,
844) -> serde_json::Value {
845 let all_edges_by_source = edges_by_source(parsed, false);
846 let production_edges_by_source = edges_by_source(parsed, true);
847 let dispatched_method_names = collect_dispatched_method_names_by_language(parsed);
848 let reachable = reachable_exports(parsed, &all_edges_by_source, &dispatched_method_names);
849 let production_reachable = reachable_exports(
850 parsed,
851 &production_edges_by_source,
852 &dispatched_method_names,
853 );
854 let test_only_callers = test_only_callers_by_target(parsed);
855 let referenced_type_names = collect_referenced_type_names(parsed);
856
857 let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
858 let mut count = 0usize;
859 let mut headline_items = Vec::new();
860 let mut generated_count = 0usize;
861 let mut generated_items = Vec::new();
862 let mut test_only_count = 0usize;
863 let mut test_only_items = Vec::new();
864 let mut uncertain_count = 0usize;
865 let mut uncertain_items: Vec<serde_json::Value> = Vec::new();
866 for contribution in parsed {
867 let generated_file = crate::inspect::generated::is_generated_file_with_cached_hint(
868 project_root,
869 &contribution.file,
870 contribution.generated,
871 );
872 if is_test_support_file(&contribution.file) {
876 continue;
877 }
878 let is_public_api_file = public_api_files.contains(&contribution.file);
879 for export in &contribution.exports {
880 if export_uses_oxc(export) {
881 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
882 LivenessVerdict::Used => {
883 if !is_test_file(&contribution.file)
884 && !export.test_only_reference_files.is_empty()
885 {
886 let mut item = json!({
887 "file": contribution.file,
888 "symbol": export.symbol,
889 "kind": export.kind,
890 "line": export.line,
891 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
892 "used_by": export.test_only_reference_files,
893 });
894 add_reexport_contexts(&mut item, &export.also_reexported);
895 if generated_file {
896 item["generated"] = json!(true);
897 generated_count += 1;
898 generated_items.push(item);
899 } else {
900 test_only_count += 1;
901 test_only_items.push(item);
902 }
903 }
904 continue;
905 }
906 LivenessVerdict::Uncertain => {
907 uncertain_count += 1;
908 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
909 let mut item = json!({
910 "file": contribution.file,
911 "symbol": export.symbol,
912 "kind": export.kind,
913 "line": export.line,
914 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
915 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
916 });
917 add_reexport_contexts(&mut item, &export.also_reexported);
918 uncertain_items.push(item);
919 }
920 continue;
921 }
922 LivenessVerdict::Unused => {
923 if !is_test_file(&contribution.file)
924 && !export.test_only_reference_files.is_empty()
925 {
926 let mut item = json!({
927 "file": contribution.file,
928 "symbol": export.symbol,
929 "kind": export.kind,
930 "line": export.line,
931 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
932 "used_by": export.test_only_reference_files,
933 });
934 add_reexport_contexts(&mut item, &export.also_reexported);
935 if generated_file {
936 item["generated"] = json!(true);
937 generated_count += 1;
938 generated_items.push(item);
939 } else {
940 test_only_count += 1;
941 test_only_items.push(item);
942 }
943 continue;
944 }
945 if export.has_references {
946 continue;
947 }
948 }
949 }
950 } else {
951 let node = (contribution.file.clone(), export.symbol.clone());
952 if !is_test_file(&contribution.file)
953 && !is_public_api_file
954 && !export.is_entry_point
955 && !production_reachable.contains(&node)
956 && test_only_callers.contains_key(&node)
957 {
958 let item = json!({
959 "file": contribution.file,
960 "symbol": export.symbol,
961 "kind": export.kind,
962 "line": export.line,
963 "provenance": CALLGRAPH_PROVENANCE_TREESITTER,
964 "used_by": test_only_callers.get(&node).cloned().unwrap_or_default(),
965 });
966 if generated_file {
967 let mut item = item;
968 item["generated"] = json!(true);
969 generated_count += 1;
970 generated_items.push(item);
971 } else {
972 test_only_count += 1;
973 test_only_items.push(item);
974 }
975 continue;
976 }
977 if reachable.contains(&node)
978 || is_public_api_file
979 || dispatch_liveness_keeps_export_live(
980 contribution,
981 export,
982 &dispatched_method_names,
983 )
984 {
985 continue;
986 }
987
988 if (export.is_type_like || is_type_like_kind(&export.kind))
989 && referenced_type_names.contains(symbol_liveness_name(&export.symbol))
990 {
991 continue;
992 }
993 }
994
995 let mut item = json!({
996 "file": contribution.file,
997 "symbol": export.symbol,
998 "kind": export.kind,
999 "line": export.line,
1000 });
1001 if let Some(provenance) = &export.provenance {
1002 item["provenance"] = json!(provenance);
1003 }
1004 add_reexport_contexts(&mut item, &export.also_reexported);
1005 if generated_file {
1006 item["generated"] = json!(true);
1007 generated_count += 1;
1008 generated_items.push(item);
1009 } else {
1010 count += 1;
1011 *by_language
1012 .entry(language_for_file(&contribution.file).to_string())
1013 .or_default() += 1;
1014 headline_items.push(item);
1015 }
1016 }
1017 }
1018
1019 let headline_items = crate::inspect::entry_points::rank_and_truncate_items(
1020 headline_items,
1021 roles,
1022 drill_down_limit,
1023 );
1024 let generated_items = crate::inspect::entry_points::rank_and_truncate_items(
1025 generated_items,
1026 roles,
1027 drill_down_limit,
1028 );
1029 let top = crate::inspect::entry_points::top_preview_symbols(&headline_items);
1030 let mut dead_items = headline_items;
1031 dead_items.extend(generated_items.iter().cloned());
1032 if let Some(limit) = drill_down_limit {
1033 dead_items.truncate(limit);
1034 }
1035 let generated_top = generated_items
1036 .iter()
1037 .take(crate::inspect::entry_points::TOP_PREVIEW_ITEMS)
1038 .cloned()
1039 .collect::<Vec<_>>();
1040 let test_only_items = crate::inspect::entry_points::rank_and_truncate_items(
1041 test_only_items,
1042 roles,
1043 drill_down_limit,
1044 );
1045 let test_only_top = test_only_items
1046 .iter()
1047 .take(crate::inspect::entry_points::TOP_PREVIEW_ITEMS)
1048 .cloned()
1049 .collect::<Vec<_>>();
1050
1051 let (parse_errors, skipped_files, languages_skipped) = dead_code_honesty_fields(parsed);
1052 let mut aggregate = json!({
1053 "count": count,
1054 "generated_count": generated_count,
1055 "total_count": count + test_only_count + generated_count,
1056 "items": dead_items,
1057 "top": top,
1058 "generated_items": generated_items,
1059 "generated_top": generated_top,
1060 "test_only_count": test_only_count,
1061 "test_only_items": test_only_items,
1062 "test_only_top": test_only_top,
1063 "by_language": by_language,
1064 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
1065 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
1066 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
1067 "uncertain_count": uncertain_count,
1068 "uncertain_items": uncertain_items,
1069 "languages_skipped": languages_skipped,
1070 "callgraph_available": true,
1071 "scanned_files": scanned_files,
1072 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
1073 });
1074 if !parse_errors.is_empty() {
1075 aggregate["parse_errors"] = Value::Array(parse_errors);
1076 }
1077 if !skipped_files.is_empty() {
1078 aggregate["skipped_files"] = Value::Array(skipped_files);
1079 }
1080 aggregate
1081}
1082
1083fn add_reexport_contexts(item: &mut Value, contexts: &[OxcReExportContext]) {
1084 if !contexts.is_empty() {
1085 item["also_reexported"] = json!(contexts);
1086 }
1087}
1088
1089fn export_uses_oxc(export: &ExportContribution) -> bool {
1090 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
1091}
1092
1093fn dead_code_honesty_fields(
1094 parsed: &[DeadCodeContribution],
1095) -> (Vec<Value>, Vec<Value>, Vec<String>) {
1096 let mut parse_error_keys = BTreeSet::new();
1097 let mut parse_errors = Vec::new();
1098 let mut skipped_file_keys = BTreeSet::new();
1099 let mut skipped_files = Vec::new();
1100 let mut languages_skipped = BTreeSet::new();
1101 for contribution in parsed {
1102 for value in &contribution.parse_errors {
1103 let key = value.to_string();
1104 if parse_error_keys.insert(key) {
1105 parse_errors.push(value.clone());
1106 }
1107 }
1108 for value in &contribution.skipped_files {
1109 let key = value.to_string();
1110 if skipped_file_keys.insert(key) {
1111 skipped_files.push(value.clone());
1112 }
1113 }
1114 languages_skipped.extend(contribution.skipped_languages.iter().cloned());
1115 }
1116 (
1117 parse_errors,
1118 skipped_files,
1119 languages_skipped.into_iter().collect(),
1120 )
1121}
1122
1123fn edges_by_source(
1124 contributions: &[DeadCodeContribution],
1125 exclude_test_origins: bool,
1126) -> BTreeMap<ExportNode, BTreeSet<ExportNode>> {
1127 let mut edges: BTreeMap<ExportNode, BTreeSet<ExportNode>> = BTreeMap::new();
1128
1129 for contribution in contributions {
1130 for call in &contribution.internal_calls {
1131 if exclude_test_origins && call.test_origin == Some(true) {
1132 continue;
1133 }
1134 if call.caller_symbol.is_empty() {
1142 continue;
1143 }
1144 let target = (call.file.clone(), call.symbol.clone());
1145 let source = (contribution.file.clone(), call.caller_symbol.clone());
1146 edges.entry(source).or_default().insert(target);
1147 }
1148 }
1149
1150 edges
1151}
1152
1153fn test_only_callers_by_target(
1154 contributions: &[DeadCodeContribution],
1155) -> BTreeMap<ExportNode, Vec<String>> {
1156 let mut callers: BTreeMap<ExportNode, (bool, BTreeSet<String>)> = BTreeMap::new();
1157 for contribution in contributions {
1158 for call in &contribution.internal_calls {
1159 let Some(test_origin) = call.test_origin else {
1160 continue;
1161 };
1162 let target = (call.file.clone(), call.symbol.clone());
1163 let summary = callers
1164 .entry(target)
1165 .or_insert_with(|| (true, BTreeSet::new()));
1166 if test_origin {
1167 summary.1.insert(contribution.file.clone());
1168 } else {
1169 summary.0 = false;
1170 }
1171 }
1172 }
1173 callers
1174 .into_iter()
1175 .filter_map(|(target, (all_test, files))| {
1176 (all_test && !files.is_empty()).then(|| (target, files.into_iter().collect()))
1177 })
1178 .collect()
1179}
1180
1181fn collect_dispatched_method_names_by_language(
1182 contributions: &[DeadCodeContribution],
1183) -> MethodNamesByLanguage {
1184 let mut by_language: MethodNamesByLanguage = BTreeMap::new();
1185 for contribution in contributions {
1186 let language = language_for_file(&contribution.file).to_string();
1187 by_language
1188 .entry(language)
1189 .or_default()
1190 .extend(contribution.dispatched_method_names.iter().cloned());
1191 }
1192 by_language
1193}
1194
1195fn collect_referenced_type_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
1196 contributions
1207 .iter()
1208 .flat_map(|contribution| contribution.type_ref_names.iter().cloned())
1209 .collect()
1210}
1211
1212fn reachable_exports(
1213 contributions: &[DeadCodeContribution],
1214 edges_by_source: &BTreeMap<ExportNode, BTreeSet<ExportNode>>,
1215 dispatched_method_names: &MethodNamesByLanguage,
1216) -> BTreeSet<ExportNode> {
1217 let imported_exports_by_file = imported_exports_by_file(contributions);
1218 let namespace_imports_by_file = namespace_imported_exports_by_file(contributions);
1219 let dispatch_live_source_names_by_file =
1220 dispatch_live_source_names_by_file(contributions, dispatched_method_names);
1221 let mut expanded_file_imports = BTreeSet::new();
1222 let mut reachable = BTreeSet::new();
1223 let mut queue = VecDeque::new();
1224
1225 for contribution in contributions {
1226 for root in &contribution.liveness_roots {
1227 queue.push_back((contribution.file.clone(), root.clone()));
1228 }
1229 for export in &contribution.exports {
1230 if export.is_entry_point {
1231 queue.push_back((contribution.file.clone(), export.symbol.clone()));
1232 }
1233 }
1234 }
1235
1236 for source in edges_by_source.keys() {
1242 if dispatch_live_source_names_by_file
1243 .get(&source.0)
1244 .is_some_and(|method_names| method_names.contains(symbol_liveness_name(&source.1)))
1245 {
1246 queue.push_back(source.clone());
1247 }
1248 }
1249
1250 while let Some(node) = queue.pop_front() {
1251 if !reachable.insert(node.clone()) {
1252 continue;
1253 }
1254 if expanded_file_imports.insert(node.0.clone()) {
1255 if let Some(targets) = imported_exports_by_file.get(&node.0) {
1261 for target in targets {
1262 if !reachable.contains(target) {
1263 queue.push_back(target.clone());
1264 }
1265 }
1266 }
1267
1268 if let Some(targets) = namespace_imports_by_file.get(&node.0) {
1272 for target in targets {
1273 if !reachable.contains(target) {
1274 queue.push_back(target.clone());
1275 }
1276 }
1277 }
1278 }
1279 if let Some(targets) = edges_by_source.get(&node) {
1280 for target in targets {
1281 if !reachable.contains(target) {
1282 queue.push_back(target.clone());
1283 }
1284 }
1285 }
1286 }
1287
1288 reachable
1289}
1290
1291fn dispatch_live_source_names_by_file(
1292 contributions: &[DeadCodeContribution],
1293 dispatched_method_names: &MethodNamesByLanguage,
1294) -> BTreeMap<String, BTreeSet<String>> {
1295 let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1296 for contribution in contributions {
1297 let language = language_for_file(&contribution.file);
1298 let Some(language_method_names) = dispatched_method_names.get(language) else {
1299 continue;
1300 };
1301 if language != "go" {
1302 by_file
1303 .entry(contribution.file.clone())
1304 .or_default()
1305 .extend(language_method_names.iter().cloned());
1306 continue;
1307 }
1308
1309 for export in &contribution.exports {
1310 if export_is_method(export)
1311 && language_method_names.contains(symbol_liveness_name(&export.symbol))
1312 {
1313 by_file
1314 .entry(contribution.file.clone())
1315 .or_default()
1316 .insert(symbol_liveness_name(&export.symbol).to_string());
1317 }
1318 }
1319 }
1320 by_file
1321}
1322
1323fn dispatch_liveness_keeps_export_live(
1324 contribution: &DeadCodeContribution,
1325 export: &ExportContribution,
1326 dispatched_method_names: &MethodNamesByLanguage,
1327) -> bool {
1328 let language = language_for_file(&contribution.file);
1329 let Some(method_names) = dispatched_method_names.get(language) else {
1330 return false;
1331 };
1332 let name_is_dispatched = method_names.contains(symbol_liveness_name(&export.symbol));
1333 if language == "go" {
1334 export_is_method(export) && name_is_dispatched
1335 } else {
1336 name_is_dispatched
1337 }
1338}
1339
1340fn export_is_method(export: &ExportContribution) -> bool {
1341 export.kind == "method"
1342}
1343
1344fn imported_exports_by_file(
1345 contributions: &[DeadCodeContribution],
1346) -> BTreeMap<String, BTreeSet<ExportNode>> {
1347 let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1348
1349 for contribution in contributions {
1350 if contribution.imported_exports.is_empty() {
1351 continue;
1352 }
1353 by_file
1354 .entry(contribution.file.clone())
1355 .or_default()
1356 .extend(
1357 contribution
1358 .imported_exports
1359 .iter()
1360 .map(|root| (root.file.clone(), root.symbol.clone())),
1361 );
1362 }
1363
1364 by_file
1365}
1366
1367fn namespace_imported_exports_by_file(
1368 contributions: &[DeadCodeContribution],
1369) -> BTreeMap<String, BTreeSet<ExportNode>> {
1370 let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1371
1372 for contribution in contributions {
1373 if contribution.namespace_imported_exports.is_empty() {
1374 continue;
1375 }
1376 by_file
1377 .entry(contribution.file.clone())
1378 .or_default()
1379 .extend(
1380 contribution
1381 .namespace_imported_exports
1382 .iter()
1383 .map(|root| (root.file.clone(), root.symbol.clone())),
1384 );
1385 }
1386
1387 by_file
1388}
1389
1390fn project_internal_call(
1391 project_root: &Path,
1392 call: &CallgraphOutboundCall,
1393 caller_file: &str,
1394 test_origin: bool,
1395 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1396 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1397) -> Option<InternalCall> {
1398 let target = parse_target(project_root, &call.target);
1399 let symbol = target.symbol?;
1400 let file = match target.file {
1401 Some(file) => file,
1409 None => resolve_unqualified_target(
1410 caller_file,
1411 &symbol,
1412 exported_symbols_by_file,
1413 files_by_exported_symbol,
1414 )?,
1415 };
1416
1417 Some(InternalCall {
1418 caller_symbol: call.caller_symbol.clone(),
1419 file,
1420 symbol,
1421 line: call.line,
1422 provenance: call.provenance.clone(),
1423 test_origin: Some(test_origin),
1424 })
1425}
1426
1427fn resolve_macro_token_liveness_edges(
1428 _project_root: &Path,
1429 caller_file: &str,
1430 refs: &[MacroTokenRefContribution],
1431 rust_imports: &[RawImportContribution],
1432 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1433) -> Vec<InternalCall> {
1434 let mut calls = Vec::new();
1435 for reference in refs {
1436 let Some((file, symbol)) = resolve_macro_token_ref_target(
1437 caller_file,
1438 reference,
1439 rust_imports,
1440 exported_symbols_by_file,
1441 ) else {
1442 continue;
1443 };
1444 calls.push(InternalCall {
1445 caller_symbol: reference.caller_symbol.clone(),
1446 file,
1447 symbol,
1448 line: reference.line,
1449 provenance: MACRO_TOKEN_LIVENESS_PROVENANCE.to_string(),
1450 test_origin: None,
1451 });
1452 }
1453 sort_dedup_internal_calls(&mut calls);
1454 calls
1455}
1456
1457fn resolve_macro_token_ref_target(
1458 caller_file: &str,
1459 reference: &MacroTokenRefContribution,
1460 rust_imports: &[RawImportContribution],
1461 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1462) -> Option<ExportNode> {
1463 let path = reference.path.as_deref().unwrap_or(&[]);
1464 match reference.shape.as_str() {
1465 RUST_MACRO_REF_SHAPE_CALL => resolve_macro_call_or_struct_ref(
1466 caller_file,
1467 path,
1468 &reference.name,
1469 rust_imports,
1470 exported_symbols_by_file,
1471 ),
1472 RUST_MACRO_REF_SHAPE_STRUCT => resolve_macro_call_or_struct_ref(
1473 caller_file,
1474 path,
1475 &reference.name,
1476 rust_imports,
1477 exported_symbols_by_file,
1478 ),
1479 RUST_MACRO_REF_SHAPE_METHOD => resolve_macro_method_ref(
1480 caller_file,
1481 path,
1482 &reference.name,
1483 rust_imports,
1484 exported_symbols_by_file,
1485 ),
1486 _ => None,
1487 }
1488}
1489
1490fn resolve_macro_call_or_struct_ref(
1491 caller_file: &str,
1492 path: &[String],
1493 name: &str,
1494 rust_imports: &[RawImportContribution],
1495 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1496) -> Option<ExportNode> {
1497 if path.is_empty() {
1498 if let Some(target) = exported_symbol_target(caller_file, name, exported_symbols_by_file) {
1499 return Some(target);
1500 }
1501 return unique_macro_target(imported_macro_targets_for_local(
1502 caller_file,
1503 name,
1504 rust_imports,
1505 exported_symbols_by_file,
1506 ));
1507 }
1508
1509 let scoped_symbol = macro_scoped_symbol(path, name);
1510 if let Some(target) =
1511 exported_symbol_target(caller_file, &scoped_symbol, exported_symbols_by_file)
1512 {
1513 return Some(target);
1514 }
1515
1516 unique_macro_target(resolve_macro_module_targets(
1517 caller_file,
1518 path,
1519 name,
1520 rust_imports,
1521 exported_symbols_by_file,
1522 ))
1523}
1524
1525fn resolve_macro_method_ref(
1526 caller_file: &str,
1527 path: &[String],
1528 name: &str,
1529 rust_imports: &[RawImportContribution],
1530 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1531) -> Option<ExportNode> {
1532 let (type_name, module_path) = path.split_last()?;
1533 let scoped_symbol = macro_scoped_symbol(path, name);
1534 if let Some(target) =
1535 exported_symbol_target(caller_file, &scoped_symbol, exported_symbols_by_file)
1536 {
1537 return Some(target);
1538 }
1539
1540 let target_symbol = format!("{type_name}::{name}");
1541 let mut targets = BTreeSet::new();
1542 if module_path.is_empty() {
1543 for (file, imported_type) in imported_macro_targets_for_local(
1544 caller_file,
1545 type_name,
1546 rust_imports,
1547 exported_symbols_by_file,
1548 ) {
1549 let imported_method = format!("{imported_type}::{name}");
1550 if let Some(target) =
1551 exported_symbol_target(&file, &imported_method, exported_symbols_by_file)
1552 {
1553 targets.insert(target);
1554 }
1555 }
1556 } else {
1557 targets.extend(resolve_macro_module_targets(
1558 caller_file,
1559 module_path,
1560 &target_symbol,
1561 rust_imports,
1562 exported_symbols_by_file,
1563 ));
1564 }
1565 unique_macro_target(targets)
1566}
1567
1568fn resolve_macro_module_targets(
1569 caller_file: &str,
1570 module_path: &[String],
1571 target_symbol: &str,
1572 rust_imports: &[RawImportContribution],
1573 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1574) -> BTreeSet<ExportNode> {
1575 let mut targets = BTreeSet::new();
1576 for candidate in rust_macro_module_path_candidates(module_path, rust_imports) {
1577 let segment_refs = candidate.iter().map(String::as_str).collect::<Vec<_>>();
1578 let Some(resolved_segments) = rust_resolve_segments_for_macro(caller_file, &segment_refs)
1579 else {
1580 continue;
1581 };
1582 let Some(file) = rust_file_for_segments_from_contributions(
1583 caller_file,
1584 &resolved_segments,
1585 exported_symbols_by_file,
1586 ) else {
1587 continue;
1588 };
1589 if let Some(target) = exported_symbol_target(&file, target_symbol, exported_symbols_by_file)
1590 {
1591 targets.insert(target);
1592 }
1593 }
1594 targets
1595}
1596
1597fn imported_macro_targets_for_local(
1598 caller_file: &str,
1599 local_name: &str,
1600 rust_imports: &[RawImportContribution],
1601 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1602) -> BTreeSet<ExportNode> {
1603 let mut targets = BTreeSet::new();
1604 for import in rust_imports {
1605 for imported in rust_imported_symbol_specs(import) {
1606 if imported.local_name != local_name {
1607 continue;
1608 }
1609 let segment_refs = imported
1610 .module_segments
1611 .iter()
1612 .map(String::as_str)
1613 .collect::<Vec<_>>();
1614 let Some(resolved_segments) =
1615 rust_resolve_segments_for_macro(caller_file, &segment_refs)
1616 else {
1617 continue;
1618 };
1619 let Some(file) = rust_file_for_segments_from_contributions(
1620 caller_file,
1621 &resolved_segments,
1622 exported_symbols_by_file,
1623 ) else {
1624 continue;
1625 };
1626 if let Some(target) =
1627 exported_symbol_target(&file, &imported.imported_name, exported_symbols_by_file)
1628 {
1629 targets.insert(target);
1630 }
1631 }
1632 }
1633 targets
1634}
1635
1636fn rust_macro_module_path_candidates(
1637 path: &[String],
1638 rust_imports: &[RawImportContribution],
1639) -> Vec<Vec<String>> {
1640 let mut candidates = Vec::new();
1641 if let Some(first) = path.first() {
1642 for import in rust_imports {
1643 let Some((local_name, mut import_segments)) = rust_import_module_alias_segments(import)
1644 else {
1645 continue;
1646 };
1647 if &local_name == first {
1648 import_segments.extend(path[1..].iter().cloned());
1649 push_unique_macro_path_candidate(&mut candidates, import_segments);
1650 }
1651 }
1652 }
1653 push_unique_macro_path_candidate(&mut candidates, path.to_vec());
1654 candidates
1655}
1656
1657fn rust_import_module_alias_segments(
1658 import: &RawImportContribution,
1659) -> Option<(String, Vec<String>)> {
1660 let path = import.source.trim().trim_end_matches(';').trim();
1661 if path.contains("::{") || path.contains('{') || path.contains('*') {
1662 return None;
1663 }
1664 let (path_without_alias, alias) = path
1665 .split_once(" as ")
1666 .map(|(left, right)| (left.trim(), Some(right.trim())))
1667 .unwrap_or((path, None));
1668 let segments = rust_path_segments(path_without_alias);
1669 let local_name = alias.or_else(|| segments.last().map(String::as_str))?;
1670 if rust_macro_name_is_upper_camel(local_name) {
1671 return None;
1672 }
1673 Some((local_name.to_string(), segments))
1674}
1675
1676fn rust_imported_symbol_specs(import: &RawImportContribution) -> Vec<RustImportedSymbolSpec> {
1677 let path = import.source.trim().trim_end_matches(';').trim();
1678 if let Some((prefix, rest)) = path.split_once("::{") {
1679 let list = rest.trim_end_matches('}');
1680 return list
1681 .split(',')
1682 .filter_map(|specifier| rust_imported_symbol_spec(prefix, specifier))
1683 .collect();
1684 }
1685
1686 rust_imported_symbol_spec("", path).into_iter().collect()
1687}
1688
1689fn rust_imported_symbol_spec(prefix: &str, specifier: &str) -> Option<RustImportedSymbolSpec> {
1690 let specifier = specifier.trim();
1691 if specifier.is_empty() || specifier == "*" || specifier.contains('{') {
1692 return None;
1693 }
1694 let (path_without_alias, alias) = specifier
1695 .split_once(" as ")
1696 .map(|(left, right)| (left.trim(), Some(right.trim())))
1697 .unwrap_or((specifier, None));
1698 let mut segments = rust_path_segments(path_without_alias);
1699 let imported_name = segments.pop()?;
1700 let local_name = alias.unwrap_or(imported_name.as_str()).trim();
1701 if local_name.is_empty() || local_name == "_" {
1702 return None;
1703 }
1704
1705 let mut module_segments = rust_path_segments(prefix);
1706 module_segments.extend(segments);
1707 Some(RustImportedSymbolSpec {
1708 local_name: local_name.to_string(),
1709 module_segments,
1710 imported_name,
1711 })
1712}
1713
1714fn rust_path_segments(path: &str) -> Vec<String> {
1715 path.split("::")
1716 .map(str::trim)
1717 .filter(|segment| !segment.is_empty())
1718 .map(str::to_string)
1719 .collect()
1720}
1721
1722fn push_unique_macro_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
1723 if !candidates.iter().any(|existing| existing == &candidate) {
1724 candidates.push(candidate);
1725 }
1726}
1727
1728fn rust_resolve_segments_for_macro(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
1729 if segments.is_empty() {
1730 return Some(Vec::new());
1731 }
1732 let caller_segments = rust_module_segments_for_rel(caller_file);
1733 match segments[0] {
1734 "crate" => Some(
1735 segments[1..]
1736 .iter()
1737 .map(|item| (*item).to_string())
1738 .collect(),
1739 ),
1740 "self" => {
1741 let mut resolved = caller_segments;
1742 resolved.extend(segments[1..].iter().map(|item| (*item).to_string()));
1743 Some(resolved)
1744 }
1745 "super" => {
1746 let mut resolved = caller_segments;
1747 resolved.pop();
1748 resolved.extend(segments[1..].iter().map(|item| (*item).to_string()));
1749 Some(resolved)
1750 }
1751 _ => {
1752 let mut resolved = caller_segments;
1753 resolved.pop();
1754 resolved.extend(segments.iter().map(|item| (*item).to_string()));
1755 Some(resolved)
1756 }
1757 }
1758}
1759
1760fn rust_file_for_segments_from_contributions(
1761 caller_file: &str,
1762 segments: &[String],
1763 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1764) -> Option<String> {
1765 let src_prefix = rust_src_prefix_for_rel(caller_file);
1766 if segments.is_empty() {
1767 let lib = format!("{src_prefix}/lib.rs");
1768 if exported_symbols_by_file.contains_key(&lib) {
1769 return Some(lib);
1770 }
1771 let main = format!("{src_prefix}/main.rs");
1772 if exported_symbols_by_file.contains_key(&main) {
1773 return Some(main);
1774 }
1775 }
1776
1777 let candidate = if segments.is_empty() {
1778 format!("{src_prefix}/lib.rs")
1779 } else {
1780 format!("{}/{}.rs", src_prefix, segments.join("/"))
1781 };
1782 if exported_symbols_by_file.contains_key(&candidate) {
1783 return Some(candidate);
1784 }
1785 if !segments.is_empty() {
1786 let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
1787 if exported_symbols_by_file.contains_key(&mod_candidate) {
1788 return Some(mod_candidate);
1789 }
1790 }
1791 None
1792}
1793
1794fn rust_src_prefix_for_rel(rel_path: &str) -> String {
1795 rel_path
1796 .split_once("/src/")
1797 .map(|(prefix, _)| format!("{prefix}/src"))
1798 .unwrap_or_else(|| "src".to_string())
1799}
1800
1801fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
1802 let after_src = rel_path
1803 .split_once("/src/")
1804 .map(|(_, rest)| rest)
1805 .or_else(|| rel_path.strip_prefix("src/"))
1806 .unwrap_or(rel_path);
1807 if matches!(after_src, "lib.rs" | "main.rs") {
1808 return Vec::new();
1809 }
1810 if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
1811 return prefix.split('/').map(|item| item.to_string()).collect();
1812 }
1813 after_src
1814 .strip_suffix(".rs")
1815 .unwrap_or(after_src)
1816 .split('/')
1817 .map(|item| item.to_string())
1818 .collect()
1819}
1820
1821fn macro_scoped_symbol(path: &[String], name: &str) -> String {
1822 if path.is_empty() {
1823 name.to_string()
1824 } else {
1825 format!("{}::{name}", path.join("::"))
1826 }
1827}
1828
1829fn exported_symbol_target(
1830 file: &str,
1831 symbol: &str,
1832 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1833) -> Option<ExportNode> {
1834 exported_symbols_by_file
1835 .get(file)
1836 .is_some_and(|symbols| symbols.contains(symbol))
1837 .then(|| (file.to_string(), symbol.to_string()))
1838}
1839
1840fn unique_macro_target(targets: BTreeSet<ExportNode>) -> Option<ExportNode> {
1841 if targets.len() == 1 {
1842 targets.into_iter().next()
1843 } else {
1844 None
1845 }
1846}
1847
1848fn raw_imports_from_tree(
1849 source: &str,
1850 tree: &tree_sitter::Tree,
1851 lang: LangId,
1852) -> Vec<RawImportContribution> {
1853 parse_imports(source, tree, lang)
1854 .imports
1855 .into_iter()
1856 .map(|import| RawImportContribution {
1857 source: import.module_path,
1858 names: import.names,
1859 default_import: import.default_import,
1860 namespace_import: import.namespace_import,
1861 })
1862 .collect()
1863}
1864
1865fn rust_raw_import_contributions(
1866 source: &str,
1867 tree: &tree_sitter::Tree,
1868) -> Vec<RawImportContribution> {
1869 parse_imports(source, tree, LangId::Rust)
1870 .imports
1871 .into_iter()
1872 .map(|import| RawImportContribution {
1873 source: import.module_path,
1874 names: import.names,
1875 default_import: None,
1876 namespace_import: None,
1877 })
1878 .collect()
1879}
1880
1881fn rust_cfg_test_ranges(source: &str, root: tree_sitter::Node) -> Vec<RustCfgTestRange> {
1882 let mut ranges = Vec::new();
1883 let mut stack = vec![root];
1884 while let Some(node) = stack.pop() {
1885 if matches!(node.kind(), "mod_item" | "function_item" | "impl_item")
1886 && rust_node_has_cfg_test_attribute(source, node)
1887 {
1888 ranges.push(RustCfgTestRange {
1889 start_line: node.start_position().row as u32 + 1,
1890 end_line: node.end_position().row as u32 + 1,
1891 });
1892 }
1893
1894 let mut cursor = node.walk();
1895 if cursor.goto_first_child() {
1896 loop {
1897 stack.push(cursor.node());
1898 if !cursor.goto_next_sibling() {
1899 break;
1900 }
1901 }
1902 }
1903 }
1904 ranges.sort_by_key(|range| (range.start_line, range.end_line));
1905 ranges.dedup();
1906 ranges
1907}
1908
1909fn rust_node_has_cfg_test_attribute(source: &str, node: tree_sitter::Node<'_>) -> bool {
1910 let mut previous = node.prev_sibling();
1911 while let Some(attribute) = previous {
1912 match attribute.kind() {
1913 "attribute_item" => {
1914 let compact = source[attribute.byte_range()]
1915 .chars()
1916 .filter(|ch| !ch.is_whitespace())
1917 .collect::<String>();
1918 if compact
1919 .strip_prefix("#[cfg(")
1920 .and_then(|inner| inner.strip_suffix(")]"))
1921 .is_some_and(cfg_predicate_requires_test)
1922 {
1923 return true;
1924 }
1925 previous = attribute.prev_sibling();
1926 }
1927 "line_comment" | "block_comment" => previous = attribute.prev_sibling(),
1928 _ => break,
1929 }
1930 }
1931 false
1932}
1933
1934fn cfg_predicate_requires_test(predicate: &str) -> bool {
1935 if predicate == "test" {
1936 return true;
1937 }
1938 if let Some(inner) = predicate
1939 .strip_prefix("all(")
1940 .and_then(|inner| inner.strip_suffix(')'))
1941 {
1942 return split_cfg_predicates(inner)
1943 .into_iter()
1944 .any(cfg_predicate_requires_test);
1945 }
1946 if let Some(inner) = predicate
1947 .strip_prefix("any(")
1948 .and_then(|inner| inner.strip_suffix(')'))
1949 {
1950 let predicates = split_cfg_predicates(inner);
1951 return !predicates.is_empty() && predicates.into_iter().all(cfg_predicate_requires_test);
1952 }
1953 false
1954}
1955
1956fn split_cfg_predicates(input: &str) -> Vec<&str> {
1957 let mut parts = Vec::new();
1958 let mut depth = 0usize;
1959 let mut start = 0usize;
1960 for (index, ch) in input.char_indices() {
1961 match ch {
1962 '(' => depth += 1,
1963 ')' => depth = depth.saturating_sub(1),
1964 ',' if depth == 0 => {
1965 parts.push(input[start..index].trim());
1966 start = index + ch.len_utf8();
1967 }
1968 _ => {}
1969 }
1970 }
1971 let tail = input[start..].trim();
1972 if !tail.is_empty() {
1973 parts.push(tail);
1974 }
1975 parts
1976}
1977
1978fn rust_macro_token_refs(source: &str, root: tree_sitter::Node) -> Vec<MacroTokenRefContribution> {
1979 let mut refs = BTreeSet::new();
1980 let mut scope_stack = Vec::new();
1981 collect_rust_macro_token_refs(source, root, &mut scope_stack, &mut refs);
1982 refs.into_iter().collect()
1983}
1984
1985fn collect_rust_macro_token_refs(
1986 source: &str,
1987 node: tree_sitter::Node,
1988 scope_stack: &mut Vec<String>,
1989 refs: &mut BTreeSet<MacroTokenRefContribution>,
1990) {
1991 let scope_len = scope_stack.len();
1992 if node.kind() == "function_item" {
1993 if let Some(symbol) = rust_function_symbol_name(source, &node) {
1994 scope_stack.push(symbol);
1995 }
1996 }
1997
1998 if node.kind() == "macro_invocation" {
1999 if let Some(token_tree) = find_child_by_kind(node, "token_tree") {
2000 let caller_symbol = scope_stack
2001 .last()
2002 .cloned()
2003 .unwrap_or_else(|| TOP_LEVEL_SYMBOL.to_string());
2004 let mut tokens = Vec::new();
2005 collect_rust_macro_tokens(source, token_tree, &mut tokens);
2006 extract_rust_macro_token_refs(&tokens, &caller_symbol, refs);
2007 }
2008 }
2009
2010 let mut cursor = node.walk();
2011 if cursor.goto_first_child() {
2012 loop {
2013 collect_rust_macro_token_refs(source, cursor.node(), scope_stack, refs);
2014 if !cursor.goto_next_sibling() {
2015 break;
2016 }
2017 }
2018 }
2019 scope_stack.truncate(scope_len);
2020}
2021
2022fn collect_rust_macro_tokens<'a>(
2023 source: &'a str,
2024 node: tree_sitter::Node,
2025 tokens: &mut Vec<RustMacroToken<'a>>,
2026) {
2027 if rust_macro_token_node_is_opaque(node.kind()) {
2028 return;
2029 }
2030
2031 if node.child_count() == 0 {
2032 let text = node_text(source, node).trim();
2033 if !text.is_empty() {
2034 tokens.push(RustMacroToken {
2035 text,
2036 kind: node.kind(),
2037 line: node.start_position().row as u32 + 1,
2038 });
2039 }
2040 return;
2041 }
2042
2043 let mut cursor = node.walk();
2044 if cursor.goto_first_child() {
2045 loop {
2046 collect_rust_macro_tokens(source, cursor.node(), tokens);
2047 if !cursor.goto_next_sibling() {
2048 break;
2049 }
2050 }
2051 }
2052}
2053
2054fn rust_macro_token_node_is_opaque(kind: &str) -> bool {
2055 matches!(
2056 kind,
2057 "string_literal" | "raw_string_literal" | "char_literal" | "line_comment" | "block_comment"
2058 )
2059}
2060
2061fn extract_rust_macro_token_refs(
2062 tokens: &[RustMacroToken<'_>],
2063 caller_symbol: &str,
2064 refs: &mut BTreeSet<MacroTokenRefContribution>,
2065) {
2066 for index in 0..tokens.len() {
2067 let token = &tokens[index];
2068 if !rust_macro_token_is_identifier(token) || rust_macro_token_is_keyword(token.text) {
2069 continue;
2070 }
2071 if index > 0 && tokens[index - 1].text == "." {
2072 continue;
2073 }
2074 if tokens.get(index + 1).is_some_and(|next| next.text == "!") {
2075 continue;
2076 }
2077
2078 let path = rust_macro_path_before(tokens, index);
2079 let next = rust_macro_next_after_optional_turbofish(tokens, index + 1);
2080 if tokens.get(next).is_some_and(|next| next.text == "(") {
2081 let shape = if path
2082 .last()
2083 .is_some_and(|segment| rust_macro_name_is_upper_camel(segment))
2084 {
2085 RUST_MACRO_REF_SHAPE_METHOD
2086 } else {
2087 RUST_MACRO_REF_SHAPE_CALL
2088 };
2089 refs.insert(MacroTokenRefContribution {
2090 caller_symbol: caller_symbol.to_string(),
2091 line: token.line,
2092 name: token.text.to_string(),
2093 path: macro_ref_path(path),
2094 shape: shape.to_string(),
2095 });
2096 continue;
2097 }
2098
2099 if rust_macro_name_is_upper_camel(token.text)
2100 && tokens.get(index + 1).is_some_and(|next| next.text == "{")
2101 {
2102 refs.insert(MacroTokenRefContribution {
2103 caller_symbol: caller_symbol.to_string(),
2104 line: token.line,
2105 name: token.text.to_string(),
2106 path: macro_ref_path(path),
2107 shape: RUST_MACRO_REF_SHAPE_STRUCT.to_string(),
2108 });
2109 }
2110 }
2111}
2112
2113fn rust_macro_path_before(tokens: &[RustMacroToken<'_>], index: usize) -> Vec<String> {
2114 let mut segments = Vec::new();
2115 let mut cursor = index;
2116 while cursor >= 2
2117 && tokens[cursor - 1].text == "::"
2118 && rust_macro_token_is_path_segment(&tokens[cursor - 2])
2119 {
2120 segments.push(tokens[cursor - 2].text.to_string());
2121 cursor -= 2;
2122 }
2123 segments.reverse();
2124 segments
2125}
2126
2127fn rust_macro_next_after_optional_turbofish(tokens: &[RustMacroToken<'_>], index: usize) -> usize {
2128 if tokens.get(index).is_none_or(|token| token.text != "::")
2129 || tokens.get(index + 1).is_none_or(|token| token.text != "<")
2130 {
2131 return index;
2132 }
2133
2134 let mut depth = 0usize;
2135 let mut cursor = index + 1;
2136 while let Some(token) = tokens.get(cursor) {
2137 match token.text {
2138 "<" => depth += 1,
2139 ">" => {
2140 depth = depth.saturating_sub(1);
2141 if depth == 0 {
2142 return cursor + 1;
2143 }
2144 }
2145 _ => {}
2146 }
2147 cursor += 1;
2148 }
2149 index
2150}
2151
2152fn macro_ref_path(path: Vec<String>) -> Option<Vec<String>> {
2153 (!path.is_empty()).then_some(path)
2154}
2155
2156fn rust_macro_token_is_identifier(token: &RustMacroToken<'_>) -> bool {
2157 matches!(token.kind, "identifier" | "type_identifier")
2158 || rust_macro_text_is_identifier(token.text)
2159}
2160
2161fn rust_macro_token_is_path_segment(token: &RustMacroToken<'_>) -> bool {
2162 rust_macro_token_is_identifier(token)
2163 && (!rust_macro_token_is_keyword(token.text)
2164 || matches!(token.text, "crate" | "self" | "super"))
2165}
2166
2167fn rust_macro_text_is_identifier(text: &str) -> bool {
2168 let mut chars = text.chars();
2169 let Some(first) = chars.next() else {
2170 return false;
2171 };
2172 (first == '_' || first.is_ascii_alphabetic())
2173 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
2174}
2175
2176fn rust_macro_name_is_upper_camel(name: &str) -> bool {
2177 name.chars().next().is_some_and(char::is_uppercase)
2178}
2179
2180fn rust_macro_token_is_keyword(text: &str) -> bool {
2181 matches!(
2182 text,
2183 "as" | "async"
2184 | "await"
2185 | "break"
2186 | "const"
2187 | "continue"
2188 | "crate"
2189 | "dyn"
2190 | "else"
2191 | "enum"
2192 | "extern"
2193 | "false"
2194 | "fn"
2195 | "for"
2196 | "if"
2197 | "impl"
2198 | "in"
2199 | "let"
2200 | "loop"
2201 | "match"
2202 | "mod"
2203 | "move"
2204 | "mut"
2205 | "pub"
2206 | "ref"
2207 | "return"
2208 | "self"
2209 | "Self"
2210 | "static"
2211 | "struct"
2212 | "super"
2213 | "trait"
2214 | "true"
2215 | "type"
2216 | "unsafe"
2217 | "use"
2218 | "where"
2219 | "while"
2220 )
2221}
2222
2223fn rust_function_symbol_name(
2224 source: &str,
2225 function_node: &tree_sitter::Node<'_>,
2226) -> Option<String> {
2227 let name_node = function_node.child_by_field_name("name")?;
2228 let name = node_text(source, name_node).to_string();
2229 let declaration_list_owner = rust_function_declaration_list_owner(function_node);
2230
2231 match declaration_list_owner.as_ref().map(tree_sitter::Node::kind) {
2232 Some("impl_item") => {
2233 let scope_name = rust_impl_scope_name(declaration_list_owner.as_ref().unwrap(), source);
2234 if scope_name.is_empty() {
2235 Some(name)
2236 } else {
2237 Some(format!("{scope_name}::{name}"))
2238 }
2239 }
2240 Some(owner_kind) if owner_kind != "mod_item" => None,
2241 _ => {
2242 let scope_chain = rust_mod_scope_chain(function_node, source);
2243 if scope_chain.is_empty() {
2244 Some(name)
2245 } else {
2246 Some(format!("{}::{name}", scope_chain.join("::")))
2247 }
2248 }
2249 }
2250}
2251
2252fn rust_function_declaration_list_owner<'a>(
2253 function_node: &tree_sitter::Node<'a>,
2254) -> Option<tree_sitter::Node<'a>> {
2255 function_node
2256 .parent()
2257 .filter(|parent| parent.kind() == "declaration_list")
2258 .and_then(|parent| parent.parent())
2259}
2260
2261fn rust_mod_scope_chain(node: &tree_sitter::Node<'_>, source: &str) -> Vec<String> {
2262 let mut scopes = Vec::new();
2263 let mut current = node.parent();
2264 while let Some(parent) = current {
2265 if parent.kind() == "mod_item" {
2266 if let Some(name_node) = parent.child_by_field_name("name") {
2267 scopes.push(node_text(source, name_node).to_string());
2268 }
2269 }
2270 current = parent.parent();
2271 }
2272 scopes.reverse();
2273 scopes
2274}
2275
2276fn rust_impl_scope_name(impl_node: &tree_sitter::Node<'_>, source: &str) -> String {
2277 let mut type_names: Vec<String> = Vec::new();
2278 let mut child_cursor = impl_node.walk();
2279 if child_cursor.goto_first_child() {
2280 loop {
2281 let child = child_cursor.node();
2282 if child.kind() == "type_identifier" || child.kind() == "generic_type" {
2283 type_names.push(node_text(source, child).to_string());
2284 }
2285 if !child_cursor.goto_next_sibling() {
2286 break;
2287 }
2288 }
2289 }
2290
2291 if type_names.len() >= 2 {
2292 format!("{} for {}", type_names[0], type_names[1])
2293 } else if type_names.len() == 1 {
2294 type_names[0].clone()
2295 } else {
2296 String::new()
2297 }
2298}
2299
2300fn ts_raw_reexport_contributions(
2301 source: &str,
2302 root: tree_sitter::Node,
2303) -> Vec<RawReexportContribution> {
2304 let mut reexports = Vec::new();
2305 let mut cursor = root.walk();
2306 if !cursor.goto_first_child() {
2307 return reexports;
2308 }
2309
2310 loop {
2311 let node = cursor.node();
2312 if node.kind() == "export_statement" {
2313 if let Some(module_path) = export_source_module(source, node) {
2314 let line = (node.start_position().row + 1) as u32;
2315 let raw_export = node_text(source, node).trim();
2316 for specifier in ts_reexport_specifiers(raw_export) {
2317 reexports.push(RawReexportContribution {
2318 language: "ts".to_string(),
2319 source: module_path.clone(),
2320 kind: "named".to_string(),
2321 imported: Some(specifier.imported),
2322 exported: Some(specifier.exported),
2323 line,
2324 });
2325 }
2326 if raw_export.contains('*') {
2327 if let Some(namespace_export) = ts_namespace_reexport_name(raw_export) {
2328 reexports.push(RawReexportContribution {
2329 language: "ts".to_string(),
2330 source: module_path.clone(),
2331 kind: "namespace".to_string(),
2332 imported: Some("*".to_string()),
2333 exported: Some(namespace_export),
2334 line,
2335 });
2336 } else {
2337 reexports.push(RawReexportContribution {
2338 language: "ts".to_string(),
2339 source: module_path.clone(),
2340 kind: "star".to_string(),
2341 imported: Some("*".to_string()),
2342 exported: None,
2343 line,
2344 });
2345 }
2346 }
2347 }
2348 }
2349
2350 if !cursor.goto_next_sibling() {
2351 break;
2352 }
2353 }
2354
2355 reexports
2356}
2357
2358fn rust_raw_reexport_contributions(source: &str) -> Vec<RawReexportContribution> {
2359 rust_pub_use_statements(source)
2360 .into_iter()
2361 .flat_map(|(statement, line)| {
2362 rust_reexport_specifiers(&statement)
2363 .into_iter()
2364 .map(move |specifier| RawReexportContribution {
2365 language: "rust".to_string(),
2366 source: specifier.module_path.join("::"),
2367 kind: if specifier.imported == "*" {
2368 "star".to_string()
2369 } else {
2370 "named".to_string()
2371 },
2372 imported: Some(specifier.imported),
2373 exported: Some(specifier.exported),
2374 line,
2375 })
2376 })
2377 .collect()
2378}
2379
2380fn resolve_raw_reexport_liveness_edges(
2381 project_root: &Path,
2382 file_name: &str,
2383 raw_reexports: &[RawReexportContribution],
2384 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2385 default_export_symbols_by_file: &BTreeMap<String, String>,
2386) -> Vec<InternalCall> {
2387 let mut edges = Vec::new();
2388 let file = project_root.join(file_name);
2389 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
2390
2391 for raw in raw_reexports {
2392 match raw.language.as_str() {
2393 "ts" => {
2394 let Some(module_entry) = resolve_import_module_path(from_dir, &raw.source) else {
2395 continue;
2396 };
2397 edges.extend(resolve_reexport_fact_edge(
2398 project_root,
2399 file_name,
2400 &module_entry,
2401 raw.kind.as_str(),
2402 raw.imported.as_deref(),
2403 raw.exported.as_deref(),
2404 raw.line,
2405 exported_symbols_by_file,
2406 default_export_symbols_by_file,
2407 ));
2408 }
2409 "rust" => {
2410 let module_path = raw
2411 .source
2412 .split("::")
2413 .filter(|segment| !segment.is_empty())
2414 .map(str::to_string)
2415 .collect::<Vec<_>>();
2416 let Some(module_entry) =
2417 rust_module_entry_from_file(project_root, file_name, &module_path)
2418 else {
2419 continue;
2420 };
2421 edges.extend(resolve_reexport_fact_edge(
2422 project_root,
2423 file_name,
2424 &module_entry,
2425 raw.kind.as_str(),
2426 raw.imported.as_deref(),
2427 raw.exported.as_deref(),
2428 raw.line,
2429 exported_symbols_by_file,
2430 default_export_symbols_by_file,
2431 ));
2432 }
2433 _ => {}
2434 }
2435 }
2436
2437 edges
2438}
2439
2440fn resolve_oxc_reexport_liveness_edges(
2441 project_root: &Path,
2442 file_name: &str,
2443 oxc_facts: &OxcFactsContribution,
2444 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2445 default_export_symbols_by_file: &BTreeMap<String, String>,
2446) -> Vec<InternalCall> {
2447 let file = project_root.join(file_name);
2448 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
2449 let mut edges = Vec::new();
2450 for fact in &oxc_facts.re_exports {
2451 let Some(module_entry) = resolve_import_module_path(from_dir, &fact.source) else {
2452 continue;
2453 };
2454 let kind = match fact.kind {
2455 ReExportKind::Named => "named",
2456 ReExportKind::Star => "star",
2457 ReExportKind::Namespace => "namespace",
2458 };
2459 edges.extend(resolve_reexport_fact_edge(
2460 project_root,
2461 file_name,
2462 &module_entry,
2463 kind,
2464 fact.imported_name.as_deref(),
2465 fact.exported_name.as_deref(),
2466 fact.line,
2467 exported_symbols_by_file,
2468 default_export_symbols_by_file,
2469 ));
2470 }
2471 edges
2472}
2473
2474#[allow(clippy::too_many_arguments)]
2475fn resolve_reexport_fact_edge(
2476 project_root: &Path,
2477 file_name: &str,
2478 module_entry: &Path,
2479 kind: &str,
2480 imported: Option<&str>,
2481 exported: Option<&str>,
2482 line: u32,
2483 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2484 default_export_symbols_by_file: &BTreeMap<String, String>,
2485) -> Vec<InternalCall> {
2486 match kind {
2487 "star" => reexport_edges_for_all_target_symbols(
2488 project_root,
2489 file_name,
2490 "",
2491 module_entry,
2492 line,
2493 exported_symbols_by_file,
2494 default_export_symbols_by_file,
2495 true,
2496 ),
2497 "namespace" => {
2498 let namespace_export = exported.unwrap_or_default();
2499 if namespace_export.is_empty()
2500 || !file_exports_symbol(file_name, namespace_export, exported_symbols_by_file)
2501 {
2502 return Vec::new();
2503 }
2504 reexport_edges_for_all_target_symbols(
2505 project_root,
2506 file_name,
2507 namespace_export,
2508 module_entry,
2509 line,
2510 exported_symbols_by_file,
2511 default_export_symbols_by_file,
2512 false,
2513 )
2514 }
2515 _ => {
2516 let imported = imported.unwrap_or_default();
2517 let exported = exported.unwrap_or(imported);
2518 if imported.is_empty()
2519 || exported.is_empty()
2520 || !file_exports_symbol(file_name, exported, exported_symbols_by_file)
2521 {
2522 return Vec::new();
2523 }
2524 resolve_imported_export_liveness_root(
2525 project_root,
2526 module_entry,
2527 imported,
2528 exported_symbols_by_file,
2529 default_export_symbols_by_file,
2530 )
2531 .map(|(target_file, target_symbol)| {
2532 vec![InternalCall {
2533 caller_symbol: exported.to_string(),
2534 file: target_file,
2535 symbol: target_symbol,
2536 line,
2537 provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
2538 test_origin: None,
2539 }]
2540 })
2541 .unwrap_or_default()
2542 }
2543 }
2544}
2545
2546fn rust_module_entry_from_file(
2547 project_root: &Path,
2548 file_name: &str,
2549 module_path: &[String],
2550) -> Option<PathBuf> {
2551 let first = module_path.first()?;
2552 let file = project_root.join(file_name);
2553 let base_dir = file.parent().unwrap_or_else(|| Path::new("."));
2554 resolve_rust_module_file(base_dir, first)
2555}
2556
2557fn resolve_raw_imported_export_liveness_roots(
2558 project_root: &Path,
2559 file_name: &str,
2560 raw_imports: &[RawImportContribution],
2561 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2562 default_export_symbols_by_file: &BTreeMap<String, String>,
2563) -> ImportedExportLiveness {
2564 let file = project_root.join(file_name);
2565 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
2566 let mut root_exports: BTreeSet<ExportNode> = BTreeSet::new();
2567 let mut namespace_exports: BTreeSet<ExportNode> = BTreeSet::new();
2568
2569 for import in raw_imports {
2570 if import.namespace_import.is_some() {
2571 if let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) {
2572 namespace_exports.extend(resolve_namespace_import_liveness_roots(
2573 project_root,
2574 &module_entry,
2575 exported_symbols_by_file,
2576 default_export_symbols_by_file,
2577 ));
2578 }
2579 }
2580
2581 let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) else {
2582 continue;
2583 };
2584
2585 for imported_name in import
2586 .names
2587 .iter()
2588 .map(|name| specifier_imported_name(name))
2589 {
2590 if let Some(root) = resolve_imported_export_liveness_root(
2591 project_root,
2592 &module_entry,
2593 imported_name,
2594 exported_symbols_by_file,
2595 default_export_symbols_by_file,
2596 ) {
2597 root_exports.insert(root);
2598 }
2599 }
2600
2601 if import.default_import.is_some() {
2602 if let Some(root) = resolve_imported_export_liveness_root(
2603 project_root,
2604 &module_entry,
2605 "default",
2606 exported_symbols_by_file,
2607 default_export_symbols_by_file,
2608 ) {
2609 root_exports.insert(root);
2610 }
2611 }
2612 }
2613
2614 ImportedExportLiveness {
2615 root_exports: root_exports
2616 .into_iter()
2617 .map(|(file, symbol)| ImportedExportContribution { file, symbol })
2618 .collect(),
2619 namespace_exports: namespace_exports
2620 .into_iter()
2621 .map(|(file, symbol)| ImportedExportContribution { file, symbol })
2622 .collect(),
2623 }
2624}
2625
2626fn ts_reexport_specifiers(raw_export: &str) -> Vec<ReexportSpecifier> {
2627 let Some(start) = raw_export.find('{').map(|index| index + 1) else {
2628 return Vec::new();
2629 };
2630 let Some(end) = raw_export[start..].find('}').map(|index| start + index) else {
2631 return Vec::new();
2632 };
2633
2634 raw_export[start..end]
2635 .split(',')
2636 .filter_map(|specifier| {
2637 let specifier = specifier.trim();
2638 if specifier.is_empty() {
2639 return None;
2640 }
2641 let imported = specifier_imported_name(specifier).trim();
2642 let exported = specifier_local_name(specifier).trim();
2643 if imported.is_empty() || exported.is_empty() {
2644 return None;
2645 }
2646 Some(ReexportSpecifier {
2647 imported: imported.to_string(),
2648 exported: exported.to_string(),
2649 })
2650 })
2651 .collect()
2652}
2653
2654fn ts_namespace_reexport_name(raw_export: &str) -> Option<String> {
2655 let after_star = raw_export.split_once('*')?.1.trim_start();
2656 let after_as = after_star.strip_prefix("as")?.trim_start();
2657 let name = after_as
2658 .split_whitespace()
2659 .next()?
2660 .trim_matches(|ch: char| ch == '{' || ch == '}' || ch == ';' || ch == ',');
2661 (!name.is_empty()).then(|| name.to_string())
2662}
2663
2664fn reexport_edges_for_all_target_symbols(
2665 project_root: &Path,
2666 file_name: &str,
2667 namespace_export: &str,
2668 module_entry: &Path,
2669 line: u32,
2670 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2671 default_export_symbols_by_file: &BTreeMap<String, String>,
2672 match_current_export_names: bool,
2673) -> Vec<InternalCall> {
2674 let Some((_, target_symbols)) =
2675 exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
2676 else {
2677 return Vec::new();
2678 };
2679
2680 let mut edges = Vec::new();
2681 for target_symbol in target_symbols {
2682 let caller_symbol = if match_current_export_names {
2683 if !file_exports_symbol(file_name, target_symbol, exported_symbols_by_file) {
2684 continue;
2685 }
2686 target_symbol.clone()
2687 } else {
2688 namespace_export.to_string()
2689 };
2690
2691 if let Some((target_file, resolved_symbol)) = resolve_imported_export_liveness_root(
2692 project_root,
2693 module_entry,
2694 target_symbol,
2695 exported_symbols_by_file,
2696 default_export_symbols_by_file,
2697 ) {
2698 edges.push(InternalCall {
2699 caller_symbol,
2700 file: target_file,
2701 symbol: resolved_symbol,
2702 line,
2703 provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
2704 test_origin: None,
2705 });
2706 }
2707 }
2708
2709 edges
2710}
2711
2712fn resolve_rust_module_file(base_dir: &Path, module: &str) -> Option<PathBuf> {
2713 let flat = base_dir.join(format!("{module}.rs"));
2714 if flat.is_file() {
2715 return Some(flat);
2716 }
2717 let nested = base_dir.join(module).join("mod.rs");
2718 nested.is_file().then_some(nested)
2719}
2720
2721fn rust_pub_use_statements(source: &str) -> Vec<(String, u32)> {
2722 let mut statements = Vec::new();
2723 let mut current = String::new();
2724 let mut start_line = 0u32;
2725
2726 for (index, line) in source.lines().enumerate() {
2727 let trimmed = line.trim();
2728 if current.is_empty() {
2729 if !(trimmed.starts_with("pub use ") || trimmed.starts_with("pub(crate) use ")) {
2730 continue;
2731 }
2732 start_line = (index + 1) as u32;
2733 }
2734
2735 current.push(' ');
2736 current.push_str(trimmed);
2737 if trimmed.ends_with(';') {
2738 statements.push((current.trim().to_string(), start_line));
2739 current.clear();
2740 }
2741 }
2742
2743 statements
2744}
2745
2746fn rust_reexport_specifiers(statement: &str) -> Vec<RustReexportSpecifier> {
2747 let statement = statement
2748 .trim()
2749 .trim_end_matches(';')
2750 .strip_prefix("pub(crate) use ")
2751 .or_else(|| {
2752 statement
2753 .trim()
2754 .trim_end_matches(';')
2755 .strip_prefix("pub use ")
2756 })
2757 .unwrap_or("")
2758 .trim();
2759 if statement.is_empty() {
2760 return Vec::new();
2761 }
2762
2763 if let Some((module_path, grouped)) = statement.split_once("::{") {
2764 let grouped = grouped.trim_end_matches('}');
2765 return grouped
2766 .split(',')
2767 .filter_map(|specifier| rust_reexport_specifier(module_path.trim(), specifier.trim()))
2768 .collect();
2769 }
2770
2771 let Some((module_path, imported)) = statement.rsplit_once("::") else {
2772 return Vec::new();
2773 };
2774 rust_reexport_specifier(module_path.trim(), imported.trim())
2775 .into_iter()
2776 .collect()
2777}
2778
2779fn rust_reexport_specifier(module_path: &str, specifier: &str) -> Option<RustReexportSpecifier> {
2780 if specifier.is_empty() {
2781 return None;
2782 }
2783 let (imported, exported) = specifier
2784 .split_once(" as ")
2785 .map(|(imported, exported)| (imported.trim(), exported.trim()))
2786 .unwrap_or((specifier.trim(), specifier.trim()));
2787 if imported.is_empty() || exported.is_empty() {
2788 return None;
2789 }
2790 Some(RustReexportSpecifier {
2791 module_path: rust_normalize_module_path(module_path),
2792 imported: imported.to_string(),
2793 exported: exported.to_string(),
2794 })
2795}
2796
2797fn rust_normalize_module_path(module_path: &str) -> Vec<String> {
2798 module_path
2799 .split("::")
2800 .filter_map(|segment| {
2801 let segment = segment.trim();
2802 if segment.is_empty() || matches!(segment, "self" | "crate") {
2803 None
2804 } else {
2805 Some(segment.to_string())
2806 }
2807 })
2808 .collect()
2809}
2810
2811fn file_exports_symbol(
2812 file_name: &str,
2813 symbol: &str,
2814 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2815) -> bool {
2816 exported_symbols_by_file
2817 .get(file_name)
2818 .is_some_and(|symbols| symbols.contains(symbol))
2819}
2820
2821fn export_source_module(source: &str, node: tree_sitter::Node) -> Option<String> {
2822 node.child_by_field_name("source")
2823 .or_else(|| find_child_by_kind(node, "string"))
2824 .and_then(|source_node| string_literal_content(source, source_node))
2825}
2826
2827fn find_child_by_kind<'tree>(
2828 node: tree_sitter::Node<'tree>,
2829 kind: &str,
2830) -> Option<tree_sitter::Node<'tree>> {
2831 let mut cursor = node.walk();
2832 if !cursor.goto_first_child() {
2833 return None;
2834 }
2835 loop {
2836 let child = cursor.node();
2837 if child.kind() == kind {
2838 return Some(child);
2839 }
2840 if let Some(descendant) = find_child_by_kind(child, kind) {
2841 return Some(descendant);
2842 }
2843 if !cursor.goto_next_sibling() {
2844 break;
2845 }
2846 }
2847 None
2848}
2849
2850fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2851 let raw = node_text(source, node).trim();
2852 let quote = raw.chars().next()?;
2853 if quote != '\'' && quote != '"' {
2854 return None;
2855 }
2856 raw.strip_prefix(quote)
2857 .and_then(|value| value.strip_suffix(quote))
2858 .map(ToOwned::to_owned)
2859}
2860
2861fn node_text<'a>(source: &'a str, node: tree_sitter::Node) -> &'a str {
2862 &source[node.byte_range()]
2863}
2864
2865fn resolve_import_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2866 if is_relative_module_path(module_path) {
2867 return resolve_js_ts_module_path(from_dir, module_path);
2868 }
2869 resolve_workspace_package_import(from_dir, module_path)
2870}
2871
2872fn resolve_js_ts_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2873 resolve_module_path(from_dir, module_path)
2874 .or_else(|| resolve_esm_source_module_path(from_dir, module_path))
2875}
2876
2877fn resolve_esm_source_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2878 if !is_relative_module_path(module_path) {
2879 return None;
2880 }
2881 let base = from_dir.join(module_path);
2882 let ext = base.extension().and_then(|extension| extension.to_str())?;
2883 let candidates: &[&str] = match ext {
2884 "js" => &["ts", "tsx"],
2885 "jsx" => &["tsx", "ts"],
2886 "mjs" => &["mts", "ts"],
2887 "cjs" => &["cts", "ts"],
2888 _ => return None,
2889 };
2890
2891 candidates
2892 .iter()
2893 .map(|extension| base.with_extension(extension))
2894 .find(|candidate| candidate.is_file())
2895}
2896
2897fn is_relative_module_path(module_path: &str) -> bool {
2898 module_path.starts_with("./")
2899 || module_path.starts_with("../")
2900 || module_path == "."
2901 || module_path == ".."
2902}
2903
2904#[derive(Debug)]
2905struct ReexportSpecifier {
2906 imported: String,
2907 exported: String,
2908}
2909
2910#[derive(Debug)]
2911struct RustReexportSpecifier {
2912 module_path: Vec<String>,
2913 imported: String,
2914 exported: String,
2915}
2916
2917fn resolve_workspace_package_import(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2918 let package_name = package_name_from_import(module_path)?;
2919 let module_entry = resolve_module_path(from_dir, module_path)?;
2920 let resolved_package_name = package_name_for_file(&module_entry)?;
2921 (resolved_package_name == package_name).then_some(module_entry)
2922}
2923
2924fn package_name_from_import(module_path: &str) -> Option<String> {
2925 if module_path.starts_with('.') || module_path.starts_with('/') || module_path.starts_with('#')
2926 {
2927 return None;
2928 }
2929
2930 let mut parts = module_path.split('/');
2931 let first = parts.next()?;
2932 if first.is_empty() {
2933 return None;
2934 }
2935
2936 if first.starts_with('@') {
2937 let second = parts.next()?;
2938 (!second.is_empty()).then(|| format!("{first}/{second}"))
2939 } else {
2940 Some(first.to_string())
2941 }
2942}
2943
2944fn package_name_for_file(file: &Path) -> Option<String> {
2945 let mut current = file.parent();
2946 while let Some(dir) = current {
2947 let manifest = dir.join("package.json");
2948 if manifest.is_file() {
2949 if let Ok(source) = fs::read_to_string(&manifest) {
2950 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) {
2951 if let Some(name) = value.get("name").and_then(serde_json::Value::as_str) {
2952 return Some(name.to_string());
2953 }
2954 }
2955 }
2956 }
2957 current = dir.parent();
2958 }
2959 None
2960}
2961
2962fn resolve_namespace_import_liveness_roots(
2963 project_root: &Path,
2964 module_entry: &Path,
2965 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2966 default_export_symbols_by_file: &BTreeMap<String, String>,
2967) -> Vec<ExportNode> {
2968 let Some((_, symbols)) =
2969 exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
2970 else {
2971 return Vec::new();
2972 };
2973 let mut roots = BTreeSet::new();
2974
2975 for symbol in symbols {
2976 if let Some(root) = resolve_imported_export_liveness_root(
2977 project_root,
2978 module_entry,
2979 symbol,
2980 exported_symbols_by_file,
2981 default_export_symbols_by_file,
2982 ) {
2983 roots.insert(root);
2984 }
2985 }
2986
2987 if default_export_symbol_for_resolved_file(
2988 project_root,
2989 module_entry,
2990 default_export_symbols_by_file,
2991 )
2992 .is_some()
2993 {
2994 if let Some(root) = resolve_imported_export_liveness_root(
2995 project_root,
2996 module_entry,
2997 "default",
2998 exported_symbols_by_file,
2999 default_export_symbols_by_file,
3000 ) {
3001 roots.insert(root);
3002 }
3003 }
3004
3005 roots.into_iter().collect()
3006}
3007
3008fn resolve_imported_export_liveness_root(
3009 project_root: &Path,
3010 module_entry: &Path,
3011 imported_symbol: &str,
3012 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
3013 default_export_symbols_by_file: &BTreeMap<String, String>,
3014) -> Option<ExportNode> {
3015 let mut file_exports_symbol = |path: &Path, symbol_name: &str| {
3016 exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
3017 .is_some_and(|(_, symbols)| symbols.contains(symbol_name))
3018 };
3019 let mut file_default_export_symbol = |path: &Path| {
3020 default_export_symbol_for_resolved_file(project_root, path, default_export_symbols_by_file)
3021 .or_else(|| {
3022 exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
3023 .and_then(|(_, symbols)| {
3024 symbols.contains("default").then(|| "default".to_string())
3025 })
3026 })
3027 };
3028
3029 let (target_file, symbol) = resolve_reexported_symbol_target(
3030 module_entry,
3031 imported_symbol,
3032 &mut file_exports_symbol,
3033 &mut file_default_export_symbol,
3034 )?;
3035
3036 let (file, symbols) =
3037 exported_symbols_for_resolved_file(project_root, &target_file, exported_symbols_by_file)?;
3038 symbols.contains(&symbol).then_some((file, symbol))
3039}
3040
3041fn exported_symbols_for_resolved_file<'a>(
3042 project_root: &Path,
3043 file: &Path,
3044 exported_symbols_by_file: &'a BTreeMap<String, BTreeSet<String>>,
3045) -> Option<(String, &'a BTreeSet<String>)> {
3046 let relative = relative_path(project_root, file);
3047 if let Some(symbols) = exported_symbols_by_file.get(&relative) {
3048 return Some((relative, symbols));
3049 }
3050
3051 let canonical_root = canonicalize_normalized(project_root);
3054 let canonical_file = canonicalize_normalized(file);
3055 let relative = relative_path(&canonical_root, &canonical_file);
3056 exported_symbols_by_file
3057 .get(&relative)
3058 .map(|symbols| (relative, symbols))
3059}
3060
3061fn default_export_symbol_for_resolved_file(
3062 project_root: &Path,
3063 file: &Path,
3064 default_export_symbols_by_file: &BTreeMap<String, String>,
3065) -> Option<String> {
3066 let relative = relative_path(project_root, file);
3067 if let Some(symbol) = default_export_symbols_by_file.get(&relative) {
3068 return Some(symbol.clone());
3069 }
3070
3071 let canonical_root = canonicalize_normalized(project_root);
3074 let canonical_file = canonicalize_normalized(file);
3075 let relative = relative_path(&canonical_root, &canonical_file);
3076 default_export_symbols_by_file.get(&relative).cloned()
3077}
3078
3079fn resolve_unqualified_target(
3080 caller_file: &str,
3081 symbol: &str,
3082 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
3083 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
3084) -> Option<String> {
3085 if exported_symbols_by_file
3086 .get(caller_file)
3087 .is_some_and(|symbols| symbols.contains(symbol))
3088 {
3089 return Some(caller_file.to_string());
3090 }
3091
3092 let files = files_by_exported_symbol.get(symbol)?;
3093 if files.len() == 1 {
3094 files.iter().next().cloned()
3095 } else {
3096 None
3097 }
3098}
3099
3100fn dispatched_method_names_from_call(
3101 call: &CallgraphOutboundCall,
3102 caller_file: &str,
3103) -> Vec<String> {
3104 let mut names = BTreeSet::new();
3105 let is_go = language_for_file(caller_file) == "go";
3106 if is_go {
3107 if let Some(interface_methods) = go_well_known_interface_methods_from_call(call) {
3108 names.extend(interface_methods.iter().map(|name| (*name).to_string()));
3109 return names.into_iter().collect();
3110 }
3111 }
3112
3113 if let Some(name) = dispatched_method_name_from_call(call) {
3114 names.insert(name);
3115 }
3116 names.into_iter().collect()
3117}
3118
3119fn dispatched_method_name_from_call(call: &CallgraphOutboundCall) -> Option<String> {
3120 let (target, full_callee) = split_call_target_metadata(&call.target);
3121 if let Some(full_callee) = full_callee {
3122 return dispatched_method_name_from_callee(full_callee);
3123 }
3124 if target.contains("::") || target.contains('#') {
3125 return None;
3126 }
3127 dispatched_method_name_from_callee(target)
3128}
3129
3130fn dispatched_method_name_from_callee(callee: &str) -> Option<String> {
3131 let callee = callee.trim();
3132 if !callee.contains('.') {
3133 return None;
3134 }
3135
3136 clean_symbol(callee.rsplit('.').next()?.trim().trim_start_matches('?'))
3137}
3138
3139fn go_well_known_interface_methods_from_call(
3140 call: &CallgraphOutboundCall,
3141) -> Option<&'static [&'static str]> {
3142 let (target, full_callee) = split_call_target_metadata(&call.target);
3143 let callee = full_callee.unwrap_or(target).trim();
3144 match callee {
3148 "sort.Sort" | "sort.Stable" | "sort.IsSorted" => Some(&["Len", "Less", "Swap"]),
3149 "list.New" => Some(&["FilterValue"]),
3150 _ => None,
3151 }
3152}
3153
3154fn split_call_target_metadata(target: &str) -> (&str, Option<&str>) {
3155 target
3156 .split_once(DISPATCHED_CALLEE_SEPARATOR)
3157 .map_or((target, None), |(target, full_callee)| {
3158 (target, Some(full_callee))
3159 })
3160}
3161
3162fn symbol_liveness_name(symbol: &str) -> &str {
3163 symbol
3164 .rsplit(['.', ':', '#'])
3165 .find(|segment| !segment.is_empty())
3166 .unwrap_or(symbol)
3167}
3168
3169fn is_type_like_kind(kind: &str) -> bool {
3170 matches!(
3171 kind,
3172 "struct" | "enum" | "trait" | "type" | "type_alias" | "interface"
3173 )
3174}
3175
3176fn parse_target(project_root: &Path, target: &str) -> ParsedTarget {
3177 let (target, _) = split_call_target_metadata(target);
3178 let trimmed = target.trim();
3179 if trimmed.is_empty() {
3180 return ParsedTarget {
3181 file: None,
3182 symbol: None,
3183 };
3184 }
3185
3186 if let Some((file, symbol)) = split_file_symbol_target(project_root, trimmed, "::") {
3187 return ParsedTarget {
3188 file: Some(relative_path(project_root, Path::new(file))),
3189 symbol: clean_symbol(symbol),
3190 };
3191 }
3192
3193 if let Some((file, symbol)) = trimmed.rsplit_once('#') {
3194 return ParsedTarget {
3195 file: Some(relative_path(project_root, Path::new(file))),
3196 symbol: clean_symbol(symbol),
3197 };
3198 }
3199
3200 ParsedTarget {
3201 file: None,
3202 symbol: clean_symbol(trimmed),
3203 }
3204}
3205
3206fn split_file_symbol_target<'a>(
3207 project_root: &Path,
3208 target: &'a str,
3209 separator: &str,
3210) -> Option<(&'a str, &'a str)> {
3211 let mut search_start = 0;
3212 while let Some(offset) = target[search_start..].find(separator) {
3213 let split_at = search_start + offset;
3214 let file = &target[..split_at];
3215 let symbol = &target[split_at + separator.len()..];
3216 if !symbol.trim().is_empty() && looks_like_source_file_target(project_root, file) {
3217 return Some((file, symbol));
3218 }
3219 search_start = split_at + separator.len();
3220 }
3221 None
3222}
3223
3224fn looks_like_source_file_target(project_root: &Path, file: &str) -> bool {
3225 let path = Path::new(file);
3226 language_for_file(file) != "unknown" || path.is_file() || project_root.join(path).is_file()
3227}
3228
3229fn clean_symbol(symbol: &str) -> Option<String> {
3230 let trimmed = symbol.trim();
3231 if trimmed.is_empty() {
3232 None
3233 } else {
3234 Some(trimmed.to_string())
3235 }
3236}
3237
3238fn liveness_roots_for_file(
3239 file_name: &str,
3240 exports: &[ExportContribution],
3241 internal_calls: &[InternalCall],
3242 attribute_entry_points: &BTreeSet<String>,
3243 executable_root_exports: Option<&BTreeSet<String>>,
3244 is_liveness_root_file: bool,
3245 is_public_api_file: bool,
3246) -> Vec<String> {
3247 let mut roots = attribute_entry_points
3248 .iter()
3249 .filter_map(|symbol| clean_symbol(symbol))
3250 .collect::<BTreeSet<_>>();
3251
3252 if !is_liveness_root_file && !is_public_api_file {
3253 return roots.into_iter().collect();
3254 }
3255
3256 roots.insert("<top-level>".to_string());
3257 if is_public_api_file {
3258 roots.extend(exports.iter().map(|export| export.symbol.clone()));
3259 } else if let Some(executable_root_exports) = executable_root_exports {
3260 roots.extend(executable_root_exports.iter().cloned());
3261 } else {
3262 roots.extend(
3263 exports
3264 .iter()
3265 .filter(|export| is_explicit_liveness_symbol(file_name, &export.symbol))
3266 .map(|export| export.symbol.clone()),
3267 );
3268 roots.extend(
3269 internal_calls
3270 .iter()
3271 .map(|call| call.caller_symbol.as_str())
3272 .filter(|symbol| is_explicit_liveness_symbol(file_name, symbol))
3273 .map(str::to_string),
3274 );
3275 }
3276
3277 roots.into_iter().collect()
3278}
3279
3280fn is_explicit_liveness_symbol(file_name: &str, symbol: &str) -> bool {
3281 let symbol = symbol.rsplit("::").next().unwrap_or(symbol);
3282 if symbol == "<top-level>" {
3283 return true;
3284 }
3285
3286 let lower = symbol.to_ascii_lowercase();
3287 if matches!(
3288 lower.as_str(),
3289 "main" | "init" | "setup" | "bootstrap" | "run"
3290 ) {
3291 return true;
3292 }
3293
3294 Path::new(file_name)
3295 .file_stem()
3296 .and_then(|stem| stem.to_str())
3297 .is_some_and(|stem| stem == symbol)
3298}
3299
3300pub(crate) fn collect_public_api_files(project_root: &Path) -> BTreeSet<String> {
3301 crate::inspect::entry_points::resolve_entry_points(project_root)
3302 .public_api_files_relative(project_root)
3303}
3304
3305fn language_for_file(file: &str) -> &'static str {
3306 detect_language(Path::new(file))
3307 .map(language_name)
3308 .unwrap_or("unknown")
3309}
3310
3311fn supports_type_refs(lang: LangId) -> bool {
3312 matches!(
3313 lang,
3314 LangId::TypeScript
3315 | LangId::Tsx
3316 | LangId::JavaScript
3317 | LangId::Python
3318 | LangId::Rust
3319 | LangId::Go
3320 )
3321}
3322
3323fn collect_freshness(file: &Path) -> FileFreshness {
3324 cache_freshness::collect(file).unwrap_or_else(|_| FileFreshness {
3325 mtime: UNIX_EPOCH,
3326 size: 0,
3327 content_hash: cache_freshness::zero_hash(),
3328 })
3329}
3330
3331fn relative_path(project_root: &Path, path: &Path) -> String {
3332 let absolute = if path.is_absolute() {
3333 path.to_path_buf()
3334 } else {
3335 project_root.join(path)
3336 };
3337 let normalized_root = canonicalize_normalized(project_root);
3338 let normalized = canonicalize_normalized(&absolute);
3339 normalized
3340 .strip_prefix(&normalized_root)
3341 .unwrap_or(normalized.as_path())
3342 .to_string_lossy()
3343 .replace('\\', "/")
3344}
3345
3346fn canonical_or_normalized(project_root: &Path, path: &Path) -> PathBuf {
3347 crate::inspect::oxc_engine::normalize_input_path(project_root, path)
3353}
3354
3355fn normalize_absolute(project_root: &Path, path: &Path) -> PathBuf {
3356 let absolute = if path.is_absolute() {
3357 path.to_path_buf()
3358 } else {
3359 project_root.join(path)
3360 };
3361 normalize_path(&absolute)
3362}
3363
3364fn normalize_path(path: &Path) -> PathBuf {
3365 crate::inspect::job::normalize_path(path)
3369}
3370
3371#[derive(Debug, Clone, Deserialize)]
3372struct DeadCodeContribution {
3373 file: String,
3374 #[serde(default)]
3375 generated: Option<bool>,
3376 exports: Vec<ExportContribution>,
3377 #[serde(default)]
3378 facts_format_version: Option<u32>,
3379 #[serde(default)]
3380 raw_imports: Vec<RawImportContribution>,
3381 #[serde(default)]
3382 raw_reexports: Vec<RawReexportContribution>,
3383 #[serde(default)]
3384 rust_imports: Vec<RawImportContribution>,
3385 #[serde(default)]
3386 macro_token_refs: Vec<MacroTokenRefContribution>,
3387 #[serde(default)]
3388 attribute_entry_points: Vec<String>,
3389 #[serde(default)]
3390 cfg_test_ranges: Vec<RustCfgTestRange>,
3391 #[serde(default)]
3392 oxc_facts: Option<OxcFactsContribution>,
3393 #[serde(default)]
3394 internal_calls: Vec<InternalCallContribution>,
3395 #[serde(default)]
3396 liveness_roots: Vec<String>,
3397 #[serde(default)]
3398 imported_exports: Vec<ImportedExportContribution>,
3399 #[serde(default)]
3400 namespace_imported_exports: Vec<ImportedExportContribution>,
3401 #[serde(default)]
3402 dispatched_method_names: Vec<String>,
3403 #[serde(default)]
3404 type_ref_names: Vec<String>,
3405 #[serde(default)]
3406 parse_errors: Vec<Value>,
3407 #[serde(default)]
3408 skipped_files: Vec<Value>,
3409 #[serde(default)]
3410 skipped_languages: Vec<String>,
3411}
3412
3413#[derive(Debug, Clone, Serialize, Deserialize)]
3414struct RawImportContribution {
3415 source: String,
3416 #[serde(default)]
3417 names: Vec<String>,
3418 #[serde(default)]
3419 default_import: Option<String>,
3420 #[serde(default)]
3421 namespace_import: Option<String>,
3422}
3423
3424#[derive(Debug, Clone, Serialize, Deserialize)]
3425struct RawReexportContribution {
3426 language: String,
3427 source: String,
3428 kind: String,
3429 #[serde(default)]
3430 imported: Option<String>,
3431 #[serde(default)]
3432 exported: Option<String>,
3433 line: u32,
3434}
3435
3436#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
3437struct MacroTokenRefContribution {
3438 caller_symbol: String,
3439 line: u32,
3440 name: String,
3441 #[serde(default, skip_serializing_if = "Option::is_none")]
3442 path: Option<Vec<String>>,
3443 shape: String,
3444}
3445
3446#[derive(Debug, Clone, Deserialize)]
3447struct OxcFactsContribution {
3448 format_version: u32,
3449 content_hash: String,
3450 exports: Vec<ExportFact>,
3451 imports: Vec<ImportFact>,
3452 re_exports: Vec<ReExportFact>,
3453 dynamic_imports: Vec<DynamicImportFact>,
3454 same_file_value_references: BTreeSet<String>,
3455 used_import_bindings: BTreeSet<String>,
3456 type_referenced_import_bindings: BTreeSet<String>,
3457 value_referenced_import_bindings: BTreeSet<String>,
3458 #[serde(default)]
3459 parse_error: Option<String>,
3460}
3461
3462#[derive(Debug, Clone, Deserialize)]
3463struct ImportedExportContribution {
3464 file: String,
3465 symbol: String,
3466}
3467
3468#[derive(Debug, Clone, Deserialize)]
3469struct ExportContribution {
3470 symbol: String,
3471 kind: String,
3472 line: u32,
3473 #[serde(default)]
3474 is_type_like: bool,
3475 #[serde(default)]
3476 is_entry_point: bool,
3477 #[serde(default)]
3478 has_references: bool,
3479 #[serde(default)]
3480 test_only_reference_files: Vec<String>,
3481 #[serde(default)]
3482 verdict: Option<LivenessVerdict>,
3483 #[serde(default)]
3484 reason: Option<String>,
3485 #[serde(default)]
3486 provenance: Option<String>,
3487 #[serde(default)]
3488 also_reexported: Vec<OxcReExportContext>,
3489}
3490
3491#[derive(Debug, Clone, Deserialize)]
3492struct InternalCallContribution {
3493 #[serde(default)]
3494 caller_symbol: String,
3495 file: String,
3496 symbol: String,
3497 #[serde(default)]
3498 test_origin: Option<bool>,
3499}
3500
3501impl From<InternalCall> for InternalCallContribution {
3502 fn from(call: InternalCall) -> Self {
3503 Self {
3504 caller_symbol: call.caller_symbol,
3505 file: call.file,
3506 symbol: call.symbol,
3507 test_origin: call.test_origin,
3508 }
3509 }
3510}
3511
3512#[derive(Debug, Clone)]
3513struct InternalCall {
3514 caller_symbol: String,
3515 file: String,
3516 symbol: String,
3517 line: u32,
3518 provenance: String,
3519 test_origin: Option<bool>,
3520}
3521
3522#[derive(Debug, Clone)]
3523struct ParsedTarget {
3524 file: Option<String>,
3525 symbol: Option<String>,
3526}
3527
3528#[cfg(test)]
3529mod tests {
3530 use super::*;
3531 use std::fs;
3532 use std::path::{Path, PathBuf};
3533 use std::sync::{Arc, RwLock};
3534
3535 use crate::config::Config;
3536 use crate::inspect::job::{CALLGRAPH_PROVENANCE_TREESITTER, DISPATCHED_CALLEE_SEPARATOR};
3537 use crate::inspect::{CallgraphExport, JobKey};
3538 use crate::parser::SymbolCache;
3539
3540 fn fixture_project(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
3541 let temp_dir = tempfile::tempdir().expect("tempdir");
3542 let root = temp_dir.path().join("project");
3543 fs::create_dir_all(&root).expect("create project root");
3544
3545 let paths = files
3546 .iter()
3547 .map(|(relative, contents)| {
3548 let path = root.join(relative);
3549 if let Some(parent) = path.parent() {
3550 fs::create_dir_all(parent).expect("create parent");
3551 }
3552 fs::write(&path, contents).expect("write fixture file");
3553 path
3554 })
3555 .collect::<Vec<_>>();
3556
3557 (temp_dir, root, paths)
3558 }
3559
3560 fn job(root: &Path, scope_files: Vec<PathBuf>, snapshot: CallgraphSnapshot) -> InspectJob {
3561 InspectJob {
3562 job_id: 1,
3563 key: JobKey::for_project_category(InspectCategory::DeadCode),
3564 category: InspectCategory::DeadCode,
3565 scope_files,
3566 project_root: root.to_path_buf(),
3567 inspect_dir: root.join(".aft-cache").join("inspect"),
3568 config: Arc::new(Config {
3569 project_root: Some(root.to_path_buf()),
3570 ..Config::default()
3571 }),
3572 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3573 inspect_writer: true,
3574 callgraph_writer: true,
3575 callgraph_snapshot: Some(Arc::new(snapshot)),
3576 }
3577 }
3578
3579 fn snapshot(
3580 files: Vec<PathBuf>,
3581 exported_symbols: Vec<CallgraphExport>,
3582 outbound_calls: Vec<CallgraphOutboundCall>,
3583 ) -> CallgraphSnapshot {
3584 snapshot_with_entry_points(files, exported_symbols, outbound_calls, BTreeSet::new())
3585 }
3586
3587 fn snapshot_with_entry_points(
3588 files: Vec<PathBuf>,
3589 exported_symbols: Vec<CallgraphExport>,
3590 outbound_calls: Vec<CallgraphOutboundCall>,
3591 entry_points: BTreeSet<PathBuf>,
3592 ) -> CallgraphSnapshot {
3593 CallgraphSnapshot {
3594 generated_at: None,
3595 files,
3596 exported_symbols,
3597 outbound_calls,
3598 entry_points,
3599 entry_point_symbols: BTreeMap::new(),
3600 }
3601 }
3602
3603 fn export(root: &Path, file: &str, symbol: &str, kind: &str) -> CallgraphExport {
3604 CallgraphExport {
3605 file: root.join(file),
3606 symbol: symbol.to_string(),
3607 kind: kind.to_string(),
3608 line: 1,
3609 }
3610 }
3611
3612 fn outbound(
3613 root: &Path,
3614 caller_file: &str,
3615 caller_symbol: &str,
3616 target: &str,
3617 ) -> CallgraphOutboundCall {
3618 CallgraphOutboundCall {
3619 caller_file: root.join(caller_file),
3620 caller_symbol: caller_symbol.to_string(),
3621 target: target.to_string(),
3622 line: 1,
3623 provenance: CALLGRAPH_PROVENANCE_TREESITTER.to_string(),
3624 }
3625 }
3626
3627 fn dispatched_target(target: &str, full_callee: &str) -> String {
3628 format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{full_callee}")
3629 }
3630
3631 fn scan(job: InspectJob) -> serde_json::Value {
3632 run_dead_code_scan(&job)
3633 .outcome
3634 .expect("scan succeeds")
3635 .aggregate
3636 }
3637
3638 #[test]
3639 fn cfg_test_predicate_requires_every_possible_branch_to_be_test_only() {
3640 assert!(cfg_predicate_requires_test("test"));
3641 assert!(cfg_predicate_requires_test("all(unix,test)"));
3642 assert!(cfg_predicate_requires_test(
3643 "any(all(test,unix),all(test,windows))"
3644 ));
3645 assert!(!cfg_predicate_requires_test("any(test,unix)"));
3646 assert!(!cfg_predicate_requires_test("not(test)"));
3647 }
3648
3649 fn aggregate_has_item(aggregate: &serde_json::Value, file: &str, symbol: &str) -> bool {
3650 aggregate
3651 .get("items")
3652 .and_then(serde_json::Value::as_array)
3653 .into_iter()
3654 .flatten()
3655 .any(|item| {
3656 item.get("file").and_then(serde_json::Value::as_str) == Some(file)
3657 && item.get("symbol").and_then(serde_json::Value::as_str) == Some(symbol)
3658 })
3659 }
3660
3661 #[test]
3662 fn contributions_persist_non_generated_classification() {
3663 let (_temp_dir, root, paths) = fixture_project(&[
3664 ("src/hand.ts", "export const hand = 1;\n"),
3665 ("build.gradle", "task smokeTest {}\n"),
3666 ]);
3667 let success = run_dead_code_scan(&job(
3668 &root,
3669 paths.clone(),
3670 snapshot(paths.clone(), Vec::new(), Vec::new()),
3671 ))
3672 .outcome
3673 .expect("scan succeeds");
3674
3675 assert_eq!(success.contributions.len(), 2);
3676 assert!(success.contributions.iter().all(|contribution| {
3677 contribution
3678 .contribution
3679 .get("generated")
3680 .and_then(Value::as_bool)
3681 == Some(false)
3682 }));
3683 }
3684
3685 #[test]
3686 fn groovy_dead_code_scan_reports_language_skipped_without_fabricated_counts() {
3687 let (_temp_dir, root, paths) = fixture_project(&[(
3688 "build.gradle",
3689 "task smokeTest {\n doLast {\n println 'smoke'\n }\n}\n",
3690 )]);
3691 let aggregate = scan(job(
3692 &root,
3693 paths.clone(),
3694 snapshot(paths.clone(), Vec::new(), Vec::new()),
3695 ));
3696
3697 assert_eq!(aggregate["count"], 0);
3698 assert_eq!(aggregate["total_count"], 0);
3699 assert_eq!(
3700 aggregate["languages_skipped"],
3701 serde_json::json!(["groovy"])
3702 );
3703 assert_eq!(aggregate["by_language"], serde_json::json!({}));
3704 assert!(aggregate["items"]
3705 .as_array()
3706 .is_some_and(|items| items.is_empty()));
3707 assert_eq!(aggregate["complete"], true);
3708 }
3709
3710 fn rust_entry_scan(
3711 files: &[(&str, &str)],
3712 exports: &[(&str, &str, &str)],
3713 ) -> serde_json::Value {
3714 let (_temp_dir, root, paths) = fixture_project(files);
3715 let entry_points = [root.join("src/main.rs")]
3716 .into_iter()
3717 .collect::<BTreeSet<_>>();
3718 let exports = exports
3719 .iter()
3720 .map(|(file, symbol, kind)| export(&root, file, symbol, kind))
3721 .collect::<Vec<_>>();
3722 scan(job(
3723 &root,
3724 paths.clone(),
3725 snapshot_with_entry_points(paths, exports, Vec::new(), entry_points),
3726 ))
3727 }
3728
3729 fn scan_success_with_oxc(job: InspectJob) -> InspectScanSuccess {
3730 let entry_points = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
3731 let options = AnalyzeOptions {
3732 entry_points: job
3733 .callgraph_snapshot
3734 .as_ref()
3735 .map(|snapshot| snapshot.entry_points.iter().cloned().collect())
3736 .unwrap_or_default(),
3737 public_api_files: Vec::new(),
3738 executable_root_exports: entry_points.executable_root_exports(),
3739 force_reparse_files: Vec::new(),
3740 entry_reachability: true,
3741 };
3742 let oxc_result =
3743 crate::inspect::oxc_engine::analyze_files(&job.project_root, &job.scope_files, options)
3744 .expect("oxc analyze succeeds");
3745 run_dead_code_scan_with_oxc(&job, Some(&oxc_result))
3746 .outcome
3747 .expect("scan succeeds")
3748 }
3749
3750 fn scan_with_oxc(job: InspectJob) -> serde_json::Value {
3751 scan_success_with_oxc(job).aggregate
3752 }
3753
3754 fn aggregate_item<'a>(
3755 aggregate: &'a serde_json::Value,
3756 file: &str,
3757 symbol: &str,
3758 ) -> Option<&'a serde_json::Value> {
3759 aggregate["items"].as_array()?.iter().find(|item| {
3760 item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
3761 })
3762 }
3763
3764 fn aggregate_generated_item<'a>(
3765 aggregate: &'a serde_json::Value,
3766 file: &str,
3767 symbol: &str,
3768 ) -> Option<&'a serde_json::Value> {
3769 aggregate["generated_items"]
3770 .as_array()?
3771 .iter()
3772 .find(|item| {
3773 item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
3774 })
3775 }
3776
3777 fn aggregate_test_only_item<'a>(
3778 aggregate: &'a serde_json::Value,
3779 file: &str,
3780 symbol: &str,
3781 ) -> Option<&'a serde_json::Value> {
3782 aggregate["test_only_items"]
3783 .as_array()?
3784 .iter()
3785 .find(|item| {
3786 item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
3787 })
3788 }
3789
3790 #[test]
3791 fn oxc_dead_code_splits_test_only_references_from_headline() {
3792 let (_temp_dir, root, paths) = fixture_project(&[
3793 ("package.json", r#"{"main":"src/main.ts"}"#),
3794 (
3795 "src/main.ts",
3796 "import { productUsed } from './api';
3797export function main() { productUsed(); }
3798",
3799 ),
3800 (
3801 "src/api.ts",
3802 "export function testOnly() {}
3803export function productUsed() {}
3804",
3805 ),
3806 (
3807 "src/dead.ts",
3808 "export function plantedDead() {}
3809",
3810 ),
3811 (
3812 "src/api.test.ts",
3813 "import { testOnly } from './api';
3814testOnly();
3815",
3816 ),
3817 (
3818 "src/barrel-target.ts",
3819 "export function throughBarrel() {}
3820export function barrelDead() {}
3821",
3822 ),
3823 (
3824 "src/barrel.ts",
3825 "export { throughBarrel } from './barrel-target';
3826",
3827 ),
3828 (
3829 "src/barrel.test.ts",
3830 "import { throughBarrel } from './barrel';
3831throughBarrel();
3832",
3833 ),
3834 ]);
3835 let root = fs::canonicalize(root).expect("canonical project root");
3836 let paths = paths
3837 .into_iter()
3838 .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
3839 .collect::<Vec<_>>();
3840 let entry_points = BTreeSet::from([root.join("src/main.ts")]);
3841 let graph = snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), entry_points);
3842
3843 let aggregate = scan_with_oxc(job(&root, paths, graph));
3844
3845 assert_eq!(aggregate["count"], 2, "{aggregate:#}");
3846 assert!(aggregate_item(&aggregate, "src/dead.ts", "plantedDead").is_some());
3847 assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "barrelDead").is_some());
3848 assert!(aggregate_item(&aggregate, "src/api.ts", "testOnly").is_none());
3849 assert!(aggregate_item(&aggregate, "src/api.ts", "productUsed").is_none());
3850 assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "throughBarrel").is_none());
3851
3852 assert_eq!(aggregate["test_only_count"], 2, "{aggregate:#}");
3853 assert_eq!(
3854 aggregate_test_only_item(&aggregate, "src/api.ts", "testOnly")
3855 .and_then(|item| item["used_by"].as_array())
3856 .and_then(|items| items.first())
3857 .and_then(serde_json::Value::as_str),
3858 Some("api.test.ts")
3859 );
3860 assert_eq!(
3861 aggregate_test_only_item(&aggregate, "src/barrel-target.ts", "throughBarrel")
3862 .and_then(|item| item["used_by"].as_array())
3863 .and_then(|items| items.first())
3864 .and_then(serde_json::Value::as_str),
3865 Some("barrel.test.ts")
3866 );
3867 }
3868
3869 #[test]
3870 fn oxc_dead_code_buckets_generated_exports_below_headline() {
3871 let (_temp_dir, root, paths) = fixture_project(&[
3872 ("package.json", r#"{"main":"src/main.ts"}"#),
3873 (
3874 "src/main.ts",
3875 "console.log('main');
3876",
3877 ),
3878 (
3879 "src/hand.ts",
3880 "export function handDead() {}
3881",
3882 ),
3883 (
3884 "gen/schema_pb.ts",
3885 "export function generatedPathDead() {}
3886",
3887 ),
3888 (
3889 "src/banner.ts",
3890 "// Code generated by fixture. DO NOT EDIT.
3891export function bannerDead() {}
3892",
3893 ),
3894 ]);
3895 let root = fs::canonicalize(root).expect("canonical project root");
3896 let paths = paths
3897 .into_iter()
3898 .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
3899 .collect::<Vec<_>>();
3900 let entry_points = BTreeSet::from([root.join("src/main.ts")]);
3901 let graph = snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), entry_points);
3902
3903 let first = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3904 let second = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3905 assert_eq!(
3906 first.aggregate, second.aggregate,
3907 "twice-cold scan must be deterministic"
3908 );
3909
3910 assert_eq!(first.aggregate["count"], 1, "{:#}", first.aggregate);
3911 assert_eq!(
3912 first.aggregate["generated_count"], 2,
3913 "{:#}",
3914 first.aggregate
3915 );
3916 assert_eq!(first.aggregate["total_count"], 3, "{:#}", first.aggregate);
3917 assert!(aggregate_item(&first.aggregate, "src/hand.ts", "handDead").is_some());
3918 assert!(aggregate_generated_item(
3919 &first.aggregate,
3920 "gen/schema_pb.ts",
3921 "generatedPathDead"
3922 )
3923 .is_some());
3924 assert!(
3925 aggregate_generated_item(&first.aggregate, "src/banner.ts", "bannerDead").is_some()
3926 );
3927
3928 let item_files = first.aggregate["items"]
3929 .as_array()
3930 .expect("items")
3931 .iter()
3932 .filter_map(|item| item["file"].as_str())
3933 .collect::<Vec<_>>();
3934 assert_eq!(item_files.first(), Some(&"src/hand.ts"), "{item_files:?}");
3935
3936 let roles = crate::inspect::entry_points::resolve_project_roles(&root);
3937 let rolled_up = aggregate_dead_code_contributions_with_snapshot(
3938 &root,
3939 &graph,
3940 &first.contributions,
3941 &collect_public_api_files(&root),
3942 &roles,
3943 Some(MAX_DRILL_DOWN_ITEMS),
3944 );
3945 assert_eq!(
3946 rolled_up, first.aggregate,
3947 "cached rollup must match cold aggregate"
3948 );
3949 }
3950
3951 #[test]
3952 fn oxc_dead_code_test_file_edit_cached_rollup_matches_cold() {
3953 let (_temp_dir, root, paths) = fixture_project(&[
3954 (
3955 "src/api.ts",
3956 "export function testOnly() {}
3957export function plantedDead() {}
3958",
3959 ),
3960 (
3961 "src/api.test.ts",
3962 "import { testOnly } from './api';
3963testOnly();
3964",
3965 ),
3966 ]);
3967 let root = fs::canonicalize(root).expect("canonical project root");
3968 let paths = paths
3969 .into_iter()
3970 .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
3971 .collect::<Vec<_>>();
3972 let graph =
3973 snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), BTreeSet::new());
3974 let first = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3975 assert_eq!(first.aggregate["count"], 1, "{:#}", first.aggregate);
3976 assert_eq!(
3977 first.aggregate["test_only_count"], 1,
3978 "{:#}",
3979 first.aggregate
3980 );
3981
3982 fs::write(
3983 root.join("src/api.test.ts"),
3984 "console.log('import removed');
3985",
3986 )
3987 .expect("edit test file");
3988
3989 let cold = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3990 let changed_test = scan_success_with_oxc(job(
3991 &root,
3992 vec![root.join("src/api.test.ts")],
3993 graph.clone(),
3994 ));
3995 let mut cached_contributions = first.contributions.clone();
3996 for changed in changed_test.contributions {
3997 let slot = cached_contributions
3998 .iter_mut()
3999 .find(|contribution| contribution.file_path == changed.file_path)
4000 .expect("cached test contribution exists");
4001 *slot = changed;
4002 }
4003 let roles = crate::inspect::entry_points::resolve_project_roles(&root);
4004 let rolled_up = aggregate_dead_code_contributions_with_snapshot(
4005 &root,
4006 &graph,
4007 &cached_contributions,
4008 &collect_public_api_files(&root),
4009 &roles,
4010 Some(MAX_DRILL_DOWN_ITEMS),
4011 );
4012
4013 assert_eq!(rolled_up, cold.aggregate);
4014 assert_eq!(rolled_up["count"], 2, "{rolled_up:#}");
4015 assert_eq!(rolled_up["test_only_count"], 0, "{rolled_up:#}");
4016 }
4017
4018 #[test]
4019 fn method_dispatched_by_receiver_call_is_live() {
4020 let (_temp_dir, root, paths) = fixture_project(&[
4021 ("src/service.ts", "export class Service { render() {} }\n"),
4022 (
4023 "src/consumer.ts",
4024 "function run(service: Service) { service.render(); }\n",
4025 ),
4026 ]);
4027 let aggregate = scan(job(
4028 &root,
4029 paths.clone(),
4030 snapshot(
4031 paths,
4032 vec![export(&root, "src/service.ts", "render", "method")],
4033 vec![outbound(
4034 &root,
4035 "src/consumer.ts",
4036 "run",
4037 &dispatched_target("render", "service.render"),
4038 )],
4039 ),
4040 ));
4041
4042 assert_eq!(aggregate["count"], 0);
4043 assert_eq!(aggregate["uncertain_count"], 0);
4044 assert!(aggregate["items"].as_array().unwrap().is_empty());
4045 }
4046
4047 #[test]
4048 fn method_without_any_dispatch_is_still_dead() {
4049 let (_temp_dir, root, paths) =
4050 fixture_project(&[("src/service.ts", "export class Service { render() {} }\n")]);
4051 let aggregate = scan(job(
4052 &root,
4053 paths.clone(),
4054 snapshot(
4055 paths,
4056 vec![export(&root, "src/service.ts", "render", "method")],
4057 Vec::new(),
4058 ),
4059 ));
4060
4061 assert_eq!(aggregate["count"], 1);
4062 assert_eq!(aggregate["items"][0]["symbol"], "render");
4063 assert_eq!(aggregate["uncertain_count"], 0);
4064 }
4065
4066 #[test]
4067 fn free_function_called_from_dispatch_live_method_body_is_live() {
4068 let (_temp_dir, root, paths) = fixture_project(&[
4079 (
4080 "src/service.ts",
4081 "export class Service { render() { helper(); } }\n",
4082 ),
4083 ("src/helper.ts", "export function helper() {}\n"),
4084 (
4085 "src/consumer.ts",
4086 "function run(service: Service) { service.render(); }\n",
4087 ),
4088 ]);
4089 let helper_target = format!("{}::helper", root.join("src/helper.ts").display());
4090 let aggregate = scan(job(
4091 &root,
4092 paths.clone(),
4093 snapshot(
4094 paths,
4095 vec![
4096 export(&root, "src/service.ts", "render", "method"),
4097 export(&root, "src/helper.ts", "helper", "function"),
4098 ],
4099 vec![
4100 outbound(
4103 &root,
4104 "src/consumer.ts",
4105 "run",
4106 &dispatched_target("render", "service.render"),
4107 ),
4108 outbound(&root, "src/service.ts", "Service::render", &helper_target),
4112 ],
4113 ),
4114 ));
4115
4116 assert_eq!(
4117 aggregate["count"], 0,
4118 "free function reached via dispatch-live method body must be live: {aggregate:#}"
4119 );
4120 assert!(aggregate["items"].as_array().unwrap().is_empty());
4121 }
4122
4123 #[test]
4124 fn rust_struct_referenced_only_in_types_is_live() {
4125 let (_temp_dir, root, paths) = fixture_project(&[
4126 ("src/types.rs", "pub struct Widget { id: u64 }\n"),
4127 (
4128 "src/main.rs",
4129 "use crate::types::Widget;\nstruct Holder { value: Widget }\npub fn main(input: Widget) -> Widget { input }\n",
4130 ),
4131 ]);
4132 let aggregate = scan(job(
4133 &root,
4134 paths.clone(),
4135 snapshot_with_entry_points(
4136 paths,
4137 vec![
4138 export(&root, "src/types.rs", "Widget", "struct"),
4139 export(&root, "src/main.rs", "main", "function"),
4140 ],
4141 Vec::new(),
4142 BTreeSet::from([root.join("src/main.rs")]),
4143 ),
4144 ));
4145
4146 assert_eq!(aggregate["count"], 0);
4147 assert_eq!(aggregate["uncertain_count"], 0);
4148 assert!(aggregate["items"].as_array().unwrap().is_empty());
4149 }
4150
4151 #[test]
4152 fn ts_interface_referenced_only_in_type_annotation_is_live() {
4153 let (_temp_dir, root, paths) = fixture_project(&[
4154 ("src/types.ts", "export interface Widget { id: string }\n"),
4155 (
4156 "src/main.ts",
4157 "import type { Widget } from './types';\nexport function run(input: Widget): void {}\n",
4158 ),
4159 ]);
4160 let aggregate = scan(job(
4161 &root,
4162 paths.clone(),
4163 snapshot_with_entry_points(
4164 paths,
4165 vec![
4166 export(&root, "src/types.ts", "Widget", "interface"),
4167 export(&root, "src/main.ts", "run", "function"),
4168 ],
4169 Vec::new(),
4170 BTreeSet::from([root.join("src/main.ts")]),
4171 ),
4172 ));
4173
4174 assert_eq!(aggregate["count"], 0);
4175 assert_eq!(aggregate["uncertain_count"], 0);
4176 assert!(aggregate["items"].as_array().unwrap().is_empty());
4177 }
4178
4179 #[test]
4180 fn type_like_export_without_call_or_type_ref_is_precise_dead() {
4181 let (_temp_dir, root, paths) =
4182 fixture_project(&[("src/types.ts", "export interface Widget { id: string }\n")]);
4183 let aggregate = scan(job(
4184 &root,
4185 paths.clone(),
4186 snapshot(
4187 paths,
4188 vec![export(&root, "src/types.ts", "Widget", "interface")],
4189 Vec::new(),
4190 ),
4191 ));
4192
4193 assert_eq!(aggregate["count"], 1);
4194 assert_eq!(aggregate["items"][0]["symbol"], "Widget");
4195 assert_eq!(aggregate["uncertain_count"], 0);
4196 assert!(aggregate["uncertain_items"].as_array().unwrap().is_empty());
4197 }
4198
4199 #[test]
4200 fn rust_attribute_entry_points_seed_command_liveness() {
4201 let (_temp_dir, root, paths) = fixture_project(&[
4202 (
4203 "src/commands.rs",
4204 r#"use crate::db;
4205
4206#[tauri::command]
4207pub fn get_primers() -> String {
4208 db::helper()
4209}
4210
4211pub fn planted_dead() -> String {
4212 "dead".to_string()
4213}
4214
4215#[tauri::command]
4216fn private_command() -> String {
4217 db::private_helper()
4218}
4219"#,
4220 ),
4221 (
4222 "src/imported.rs",
4223 r#"use crate::db;
4224use tauri::command;
4225
4226#[command]
4227pub fn imported_command() -> String {
4228 db::imported_helper()
4229}
4230"#,
4231 ),
4232 (
4233 "src/unimported.rs",
4234 r#"use crate::db;
4235
4236#[command]
4237pub fn false_command() -> String {
4238 db::false_helper()
4239}
4240"#,
4241 ),
4242 (
4243 "src/db.rs",
4244 r#"pub fn helper() -> String { "live".to_string() }
4245pub fn imported_helper() -> String { "live".to_string() }
4246pub fn private_helper() -> String { "live".to_string() }
4247pub fn false_helper() -> String { "dead".to_string() }
4248"#,
4249 ),
4250 ]);
4251 let helper_target = format!("{}::helper", root.join("src/db.rs").display());
4252 let imported_helper_target =
4253 format!("{}::imported_helper", root.join("src/db.rs").display());
4254 let private_helper_target = format!("{}::private_helper", root.join("src/db.rs").display());
4255 let false_helper_target = format!("{}::false_helper", root.join("src/db.rs").display());
4256 let aggregate = scan(job(
4257 &root,
4258 paths.clone(),
4259 snapshot(
4260 paths,
4261 vec![
4262 export(&root, "src/commands.rs", "get_primers", "function"),
4263 export(&root, "src/commands.rs", "planted_dead", "function"),
4264 export(&root, "src/imported.rs", "imported_command", "function"),
4265 export(&root, "src/unimported.rs", "false_command", "function"),
4266 export(&root, "src/db.rs", "helper", "function"),
4267 export(&root, "src/db.rs", "imported_helper", "function"),
4268 export(&root, "src/db.rs", "private_helper", "function"),
4269 export(&root, "src/db.rs", "false_helper", "function"),
4270 ],
4271 vec![
4272 outbound(&root, "src/commands.rs", "get_primers", &helper_target),
4273 outbound(
4274 &root,
4275 "src/imported.rs",
4276 "imported_command",
4277 &imported_helper_target,
4278 ),
4279 outbound(
4280 &root,
4281 "src/commands.rs",
4282 "private_command",
4283 &private_helper_target,
4284 ),
4285 outbound(
4286 &root,
4287 "src/unimported.rs",
4288 "false_command",
4289 &false_helper_target,
4290 ),
4291 ],
4292 ),
4293 ));
4294
4295 assert!(!aggregate_has_item(
4296 &aggregate,
4297 "src/commands.rs",
4298 "get_primers"
4299 ));
4300 assert!(!aggregate_has_item(&aggregate, "src/db.rs", "helper"));
4301 assert!(!aggregate_has_item(
4302 &aggregate,
4303 "src/imported.rs",
4304 "imported_command"
4305 ));
4306 assert!(!aggregate_has_item(
4307 &aggregate,
4308 "src/db.rs",
4309 "imported_helper"
4310 ));
4311 assert!(!aggregate_has_item(
4312 &aggregate,
4313 "src/db.rs",
4314 "private_helper"
4315 ));
4316 assert!(aggregate_has_item(
4317 &aggregate,
4318 "src/commands.rs",
4319 "planted_dead"
4320 ));
4321 assert!(aggregate_has_item(
4322 &aggregate,
4323 "src/unimported.rs",
4324 "false_command"
4325 ));
4326 assert!(aggregate_has_item(&aggregate, "src/db.rs", "false_helper"));
4327 }
4328
4329 #[test]
4330 fn rust_macro_token_liveness_rescues_bare_join_calls() {
4331 let aggregate = rust_entry_scan(
4332 &[(
4333 "src/main.rs",
4334 "fn main() { tokio::join!(fetch_a(), fetch_b()); }\nfn fetch_a() {}\nfn fetch_b() {}\nfn dead() {}\n",
4335 )],
4336 &[
4337 ("src/main.rs", "main", "function"),
4338 ("src/main.rs", "fetch_a", "function"),
4339 ("src/main.rs", "fetch_b", "function"),
4340 ("src/main.rs", "dead", "function"),
4341 ],
4342 );
4343
4344 assert!(!aggregate_has_item(&aggregate, "src/main.rs", "fetch_a"));
4345 assert!(!aggregate_has_item(&aggregate, "src/main.rs", "fetch_b"));
4346 assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead"));
4347 }
4348
4349 #[test]
4350 fn rust_macro_token_liveness_rescues_upper_camel_component_and_nested_call() {
4351 let aggregate = rust_entry_scan(
4352 &[(
4353 "src/main.rs",
4354 "fn main() { element! { Header { title() } } }\nstruct Header;\nfn title() {}\nfn dead() {}\n",
4355 )],
4356 &[
4357 ("src/main.rs", "main", "function"),
4358 ("src/main.rs", "Header", "struct"),
4359 ("src/main.rs", "title", "function"),
4360 ("src/main.rs", "dead", "function"),
4361 ],
4362 );
4363
4364 assert!(!aggregate_has_item(&aggregate, "src/main.rs", "Header"));
4365 assert!(!aggregate_has_item(&aggregate, "src/main.rs", "title"));
4366 assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead"));
4367 }
4368
4369 #[test]
4370 fn rust_macro_token_liveness_ignores_json_string_keys_but_keeps_values() {
4371 let aggregate = rust_entry_scan(
4372 &[(
4373 "src/main.rs",
4374 "fn main() { json!({\"dead_key\": compute_x()}); }\nfn compute_x() {}\nfn dead_key() {}\n",
4375 )],
4376 &[
4377 ("src/main.rs", "main", "function"),
4378 ("src/main.rs", "compute_x", "function"),
4379 ("src/main.rs", "dead_key", "function"),
4380 ],
4381 );
4382
4383 assert!(!aggregate_has_item(&aggregate, "src/main.rs", "compute_x"));
4384 assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead_key"));
4385 }
4386
4387 #[test]
4388 fn rust_macro_token_liveness_resolves_path_qualified_calls() {
4389 let aggregate = rust_entry_scan(
4390 &[
4391 (
4392 "src/main.rs",
4393 "mod m;\nfn main() { wrapper!(m::helper()); }\n",
4394 ),
4395 ("src/m.rs", "pub fn helper() {}\npub fn dead() {}\n"),
4396 ],
4397 &[
4398 ("src/main.rs", "main", "function"),
4399 ("src/m.rs", "helper", "function"),
4400 ("src/m.rs", "dead", "function"),
4401 ],
4402 );
4403
4404 assert!(!aggregate_has_item(&aggregate, "src/m.rs", "helper"));
4405 assert!(aggregate_has_item(&aggregate, "src/m.rs", "dead"));
4406 }
4407
4408 #[test]
4409 fn rust_macro_token_liveness_rescues_turbofish_calls() {
4410 let aggregate = rust_entry_scan(
4411 &[(
4412 "src/main.rs",
4413 "fn main() { wrapper!(parse::<T>()); }\nstruct T;\nfn parse<T>() {}\nfn dead() {}\n",
4414 )],
4415 &[
4416 ("src/main.rs", "main", "function"),
4417 ("src/main.rs", "T", "struct"),
4418 ("src/main.rs", "parse", "function"),
4419 ("src/main.rs", "dead", "function"),
4420 ],
4421 );
4422
4423 assert!(!aggregate_has_item(&aggregate, "src/main.rs", "parse"));
4424 assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead"));
4425 }
4426
4427 #[test]
4428 fn rust_macro_token_liveness_does_not_rescue_receiver_methods_or_bare_idents() {
4429 let aggregate = rust_entry_scan(
4430 &[
4431 (
4432 "src/main.rs",
4433 "mod other;\nfn main() { wrapper!(socket.recv(), recv); }\n",
4434 ),
4435 ("src/other.rs", "pub fn recv() {}\n"),
4436 ],
4437 &[
4438 ("src/main.rs", "main", "function"),
4439 ("src/other.rs", "recv", "function"),
4440 ],
4441 );
4442
4443 assert!(aggregate_has_item(&aggregate, "src/other.rs", "recv"));
4444 }
4445
4446 #[test]
4447 fn rust_macro_token_liveness_inside_dead_caller_does_not_rescue_target() {
4448 let aggregate = rust_entry_scan(
4449 &[(
4450 "src/main.rs",
4451 "fn main() {}\nfn unreachable() { wrapper!(target()); }\nfn target() {}\n",
4452 )],
4453 &[
4454 ("src/main.rs", "main", "function"),
4455 ("src/main.rs", "unreachable", "function"),
4456 ("src/main.rs", "target", "function"),
4457 ],
4458 );
4459
4460 assert!(aggregate_has_item(&aggregate, "src/main.rs", "unreachable"));
4461 assert!(aggregate_has_item(&aggregate, "src/main.rs", "target"));
4462 }
4463
4464 #[test]
4465 fn genuinely_unreachable_function_is_still_dead() {
4466 let (_temp_dir, root, paths) =
4467 fixture_project(&[("src/build.ts", "export function build() {}\n")]);
4468 let aggregate = scan(job(
4469 &root,
4470 paths.clone(),
4471 snapshot(
4472 paths,
4473 vec![export(&root, "src/build.ts", "build", "function")],
4474 Vec::new(),
4475 ),
4476 ));
4477
4478 assert_eq!(aggregate["count"], 1);
4479 assert_eq!(aggregate["items"][0]["symbol"], "build");
4480 assert_eq!(aggregate["uncertain_count"], 0);
4481 }
4482}