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