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