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