1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::time::{Instant, UNIX_EPOCH};
5
6use rayon::prelude::*;
7use serde::Deserialize;
8use serde_json::json;
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::{is_test_support_file, DISPATCHED_CALLEE_SEPARATOR};
15use crate::inspect::{
16 CallgraphOutboundCall, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
17 InspectResult, InspectScanSuccess,
18};
19use crate::parser::{detect_language, grammar_for, LangId};
20
21use super::DEFAULT_EXPORT_MARKER_KIND;
22
23const MAX_DRILL_DOWN_ITEMS: usize = 100;
24
25type ExportNode = (String, String);
26
27#[derive(Debug, Default)]
28struct ImportedExportLiveness {
29 root_exports: Vec<ImportedExportContribution>,
30 namespace_exports: Vec<ImportedExportContribution>,
31}
32
33pub fn run_dead_code_scan(job: &InspectJob) -> InspectResult {
34 let started = Instant::now();
35
36 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
37 let success = InspectScanSuccess {
38 scanned_files: job.scope_files.clone(),
39 contributions: Vec::new(),
40 aggregate: callgraph_unavailable_aggregate(job.scope_files.len()),
41 };
42 return InspectResult::success(job, success, started.elapsed());
43 };
44
45 let liveness_root_files = snapshot
46 .entry_points
47 .iter()
48 .map(|file| relative_path(&job.project_root, file))
49 .collect::<BTreeSet<_>>();
50 let public_api_files = collect_public_api_files(&job.project_root);
51 let (exported_symbols_by_file, files_by_exported_symbol, default_export_symbols_by_file) =
52 exported_symbol_indexes(job, snapshot);
53
54 let contributions = job
55 .scope_files
56 .par_iter()
57 .map(|file| {
58 gather_file_contribution(
59 job,
60 snapshot,
61 file,
62 &exported_symbols_by_file,
63 &files_by_exported_symbol,
64 &default_export_symbols_by_file,
65 &liveness_root_files,
66 &public_api_files,
67 )
68 })
69 .collect::<Vec<_>>();
70
71 let roles = crate::inspect::entry_points::resolve_project_roles(&job.project_root);
72 let aggregate = aggregate_dead_code_contributions(&contributions, &public_api_files, &roles);
73 let success = InspectScanSuccess {
74 scanned_files: job.scope_files.clone(),
75 contributions,
76 aggregate,
77 };
78
79 InspectResult::success(job, success, started.elapsed())
80}
81
82fn exported_symbol_indexes(
83 job: &InspectJob,
84 snapshot: &CallgraphSnapshot,
85) -> (
86 BTreeMap<String, BTreeSet<String>>,
87 BTreeMap<String, BTreeSet<String>>,
88 BTreeMap<String, String>,
89) {
90 let mut exported_symbols_by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
91 let mut files_by_exported_symbol: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
92 let mut default_export_symbols_by_file: BTreeMap<String, String> = BTreeMap::new();
93
94 for export in &snapshot.exported_symbols {
95 let file = relative_path(&job.project_root, &export.file);
96 if export.kind == DEFAULT_EXPORT_MARKER_KIND {
97 default_export_symbols_by_file.insert(file, export.symbol.clone());
98 continue;
99 }
100
101 exported_symbols_by_file
102 .entry(file.clone())
103 .or_default()
104 .insert(export.symbol.clone());
105 files_by_exported_symbol
106 .entry(export.symbol.clone())
107 .or_default()
108 .insert(file);
109 }
110
111 (
112 exported_symbols_by_file,
113 files_by_exported_symbol,
114 default_export_symbols_by_file,
115 )
116}
117
118fn gather_file_contribution(
119 job: &InspectJob,
120 snapshot: &CallgraphSnapshot,
121 file: &Path,
122 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
123 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
124 default_export_symbols_by_file: &BTreeMap<String, String>,
125 liveness_root_files: &BTreeSet<String>,
126 public_api_files: &BTreeSet<String>,
127) -> FileContribution {
128 let file_name = relative_path(&job.project_root, file);
129 let is_liveness_root_file = liveness_root_files.contains(&file_name);
130 let is_public_api_file = public_api_files.contains(&file_name);
131 let mut exports = snapshot
132 .exported_symbols
133 .iter()
134 .filter(|export| same_file(&job.project_root, &export.file, file))
135 .filter(|export| export.kind != DEFAULT_EXPORT_MARKER_KIND)
136 .map(|export| ExportContribution {
137 symbol: export.symbol.clone(),
138 kind: export.kind.clone(),
139 line: export.line,
140 is_type_like: is_type_like_kind(&export.kind),
141 is_entry_point: false,
142 })
143 .collect::<Vec<_>>();
144
145 let mut internal_calls = snapshot
146 .outbound_calls
147 .iter()
148 .filter(|call| same_file(&job.project_root, &call.caller_file, file))
149 .filter_map(|call| {
150 project_internal_call(
151 &job.project_root,
152 call,
153 &file_name,
154 exported_symbols_by_file,
155 files_by_exported_symbol,
156 )
157 })
158 .collect::<Vec<_>>();
159 internal_calls.extend(reexport_liveness_edges(
160 &job.project_root,
161 file,
162 &file_name,
163 exported_symbols_by_file,
164 default_export_symbols_by_file,
165 ));
166 internal_calls.sort_by(|left, right| {
167 left.caller_symbol
168 .cmp(&right.caller_symbol)
169 .then_with(|| left.file.cmp(&right.file))
170 .then_with(|| left.symbol.cmp(&right.symbol))
171 .then_with(|| left.line.cmp(&right.line))
172 });
173 internal_calls.dedup_by(|left, right| {
174 left.caller_symbol == right.caller_symbol
175 && left.file == right.file
176 && left.symbol == right.symbol
177 && left.line == right.line
178 });
179
180 let dispatched_method_names = snapshot
181 .outbound_calls
182 .iter()
183 .filter(|call| same_file(&job.project_root, &call.caller_file, file))
184 .filter_map(dispatched_method_name_from_call)
185 .collect::<BTreeSet<_>>()
186 .into_iter()
187 .collect::<Vec<_>>();
188 let imported_export_liveness = imported_export_liveness_roots(
189 &job.project_root,
190 file,
191 exported_symbols_by_file,
192 default_export_symbols_by_file,
193 );
194 let type_ref_names = collect_type_ref_names(file);
195
196 let liveness_roots = liveness_roots_for_file(
197 &file_name,
198 &exports,
199 &internal_calls,
200 is_liveness_root_file,
201 is_public_api_file,
202 );
203 for export in &mut exports {
204 export.is_entry_point = liveness_roots.contains(&export.symbol);
205 }
206
207 let mut payload = json!({
208 "file": file_name,
209 "exports": exports
210 .iter()
211 .map(|export| {
212 let mut value = json!({
213 "symbol": export.symbol,
214 "kind": export.kind,
215 "line": export.line,
216 "is_entry_point": export.is_entry_point,
217 });
218 if export.is_type_like {
219 value["is_type_like"] = json!(true);
220 }
221 value
222 })
223 .collect::<Vec<_>>(),
224 "internal_calls": internal_calls
225 .into_iter()
226 .map(|call| json!({
227 "caller_symbol": call.caller_symbol,
228 "file": call.file,
229 "symbol": call.symbol,
230 "line": call.line,
231 }))
232 .collect::<Vec<_>>(),
233 "liveness_roots": liveness_roots,
234 });
235 if !dispatched_method_names.is_empty() {
236 payload["dispatched_method_names"] = json!(dispatched_method_names);
237 }
238 if !imported_export_liveness.root_exports.is_empty() {
239 payload["imported_exports"] = json!(imported_export_liveness
240 .root_exports
241 .iter()
242 .map(|root| json!({
243 "file": root.file,
244 "symbol": root.symbol,
245 }))
246 .collect::<Vec<_>>());
247 }
248 if !imported_export_liveness.namespace_exports.is_empty() {
249 payload["namespace_imported_exports"] = json!(imported_export_liveness
250 .namespace_exports
251 .iter()
252 .map(|root| json!({
253 "file": root.file,
254 "symbol": root.symbol,
255 }))
256 .collect::<Vec<_>>());
257 }
258
259 FileContribution::new(
260 InspectCategory::DeadCode,
261 file.to_path_buf(),
262 collect_freshness(file),
263 payload,
264 )
265 .with_type_ref_names(type_ref_names)
266}
267
268pub(crate) fn callgraph_unavailable_aggregate(scanned_files: usize) -> serde_json::Value {
269 json!({
270 "count": 0,
271 "items": [],
272 "by_language": {},
273 "drill_down_capped": false,
274 "uncertain_count": 0,
275 "uncertain_items": [],
276 "callgraph_available": false,
277 "scanned_files": scanned_files,
278 "notes": ["callgraph_unavailable"],
279 })
280}
281
282pub(crate) fn aggregate_dead_code_contributions(
283 contributions: &[FileContribution],
284 public_api_files: &BTreeSet<String>,
285 roles: &crate::inspect::entry_points::ProjectRoles,
286) -> serde_json::Value {
287 aggregate_dead_code_contributions_with_limit(
288 contributions,
289 public_api_files,
290 roles,
291 Some(MAX_DRILL_DOWN_ITEMS),
292 )
293}
294
295pub(crate) fn aggregate_dead_code_contributions_with_limit(
296 contributions: &[FileContribution],
297 public_api_files: &BTreeSet<String>,
298 roles: &crate::inspect::entry_points::ProjectRoles,
299 drill_down_limit: Option<usize>,
300) -> serde_json::Value {
301 let parsed = contributions
302 .iter()
303 .filter_map(|contribution| {
304 serde_json::from_value::<DeadCodeContribution>(contribution.contribution.clone()).ok()
305 })
306 .collect::<Vec<_>>();
307
308 let edges_by_source = edges_by_source(&parsed);
309 let dispatched_method_names = collect_dispatched_method_names(&parsed);
310 let reachable = reachable_exports(&parsed, &edges_by_source, &dispatched_method_names);
311 let referenced_type_names = collect_referenced_type_names(&parsed);
312
313 let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
314 let mut count = 0usize;
315 let mut dead_items = Vec::new();
316 let uncertain_count = 0usize;
317 let uncertain_items: Vec<serde_json::Value> = Vec::new();
318 for contribution in &parsed {
319 if is_test_support_file(&contribution.file) {
323 continue;
324 }
325 let is_public_api_file = public_api_files.contains(&contribution.file);
326 for export in &contribution.exports {
327 let node = (contribution.file.clone(), export.symbol.clone());
328 if reachable.contains(&node)
329 || is_public_api_file
330 || dispatched_method_names.contains(symbol_liveness_name(&export.symbol))
331 {
332 continue;
333 }
334
335 if (export.is_type_like || is_type_like_kind(&export.kind))
336 && referenced_type_names.contains(symbol_liveness_name(&export.symbol))
337 {
338 continue;
339 }
340
341 count += 1;
342 *by_language
343 .entry(language_for_file(&contribution.file).to_string())
344 .or_default() += 1;
345 dead_items.push(json!({
349 "file": contribution.file,
350 "symbol": export.symbol,
351 "kind": export.kind,
352 "line": export.line,
353 }));
354 }
355 }
356
357 let dead_items =
358 crate::inspect::entry_points::rank_and_truncate_items(dead_items, roles, drill_down_limit);
359 let top = crate::inspect::entry_points::top_preview_symbols(&dead_items);
360
361 json!({
362 "count": count,
363 "items": dead_items,
364 "top": top,
365 "by_language": by_language,
366 "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
367 "uncertain_count": uncertain_count,
368 "uncertain_items": uncertain_items,
369 "callgraph_available": true,
370 "scanned_files": contributions.len(),
371 })
372}
373
374fn edges_by_source(
375 contributions: &[DeadCodeContribution],
376) -> BTreeMap<ExportNode, BTreeSet<ExportNode>> {
377 let mut edges: BTreeMap<ExportNode, BTreeSet<ExportNode>> = BTreeMap::new();
378
379 for contribution in contributions {
380 for call in &contribution.internal_calls {
381 if call.caller_symbol.is_empty() {
389 continue;
390 }
391 let target = (call.file.clone(), call.symbol.clone());
392 let source = (contribution.file.clone(), call.caller_symbol.clone());
393 edges.entry(source).or_default().insert(target);
394 }
395 }
396
397 edges
398}
399
400fn collect_dispatched_method_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
401 contributions
402 .iter()
403 .flat_map(|contribution| contribution.dispatched_method_names.iter().cloned())
404 .collect()
405}
406
407fn collect_referenced_type_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
408 contributions
419 .iter()
420 .flat_map(|contribution| contribution.type_ref_names.iter().cloned())
421 .collect()
422}
423
424fn reachable_exports(
425 contributions: &[DeadCodeContribution],
426 edges_by_source: &BTreeMap<ExportNode, BTreeSet<ExportNode>>,
427 dispatched_method_names: &BTreeSet<String>,
428) -> BTreeSet<ExportNode> {
429 let imported_exports_by_file = imported_exports_by_file(contributions);
430 let namespace_imports_by_file = namespace_imported_exports_by_file(contributions);
431 let mut expanded_file_imports = BTreeSet::new();
432 let mut reachable = BTreeSet::new();
433 let mut queue = VecDeque::new();
434
435 for contribution in contributions {
436 for root in &contribution.liveness_roots {
437 queue.push_back((contribution.file.clone(), root.clone()));
438 }
439 for export in &contribution.exports {
440 if export.is_entry_point {
441 queue.push_back((contribution.file.clone(), export.symbol.clone()));
442 }
443 }
444 }
445
446 for source in edges_by_source.keys() {
459 if dispatched_method_names.contains(symbol_liveness_name(&source.1)) {
460 queue.push_back(source.clone());
461 }
462 }
463
464 while let Some(node) = queue.pop_front() {
465 if !reachable.insert(node.clone()) {
466 continue;
467 }
468 if expanded_file_imports.insert(node.0.clone()) {
469 if let Some(targets) = imported_exports_by_file.get(&node.0) {
475 for target in targets {
476 if !reachable.contains(target) {
477 queue.push_back(target.clone());
478 }
479 }
480 }
481
482 if let Some(targets) = namespace_imports_by_file.get(&node.0) {
486 for target in targets {
487 if !reachable.contains(target) {
488 queue.push_back(target.clone());
489 }
490 }
491 }
492 }
493 if let Some(targets) = edges_by_source.get(&node) {
494 for target in targets {
495 if !reachable.contains(target) {
496 queue.push_back(target.clone());
497 }
498 }
499 }
500 }
501
502 reachable
503}
504
505fn imported_exports_by_file(
506 contributions: &[DeadCodeContribution],
507) -> BTreeMap<String, BTreeSet<ExportNode>> {
508 let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
509
510 for contribution in contributions {
511 if contribution.imported_exports.is_empty() {
512 continue;
513 }
514 by_file
515 .entry(contribution.file.clone())
516 .or_default()
517 .extend(
518 contribution
519 .imported_exports
520 .iter()
521 .map(|root| (root.file.clone(), root.symbol.clone())),
522 );
523 }
524
525 by_file
526}
527
528fn namespace_imported_exports_by_file(
529 contributions: &[DeadCodeContribution],
530) -> BTreeMap<String, BTreeSet<ExportNode>> {
531 let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
532
533 for contribution in contributions {
534 if contribution.namespace_imported_exports.is_empty() {
535 continue;
536 }
537 by_file
538 .entry(contribution.file.clone())
539 .or_default()
540 .extend(
541 contribution
542 .namespace_imported_exports
543 .iter()
544 .map(|root| (root.file.clone(), root.symbol.clone())),
545 );
546 }
547
548 by_file
549}
550
551fn project_internal_call(
552 project_root: &Path,
553 call: &CallgraphOutboundCall,
554 caller_file: &str,
555 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
556 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
557) -> Option<InternalCall> {
558 let target = parse_target(project_root, &call.target);
559 let symbol = target.symbol?;
560 let file = match target.file {
561 Some(file) => file,
569 None => resolve_unqualified_target(
570 caller_file,
571 &symbol,
572 exported_symbols_by_file,
573 files_by_exported_symbol,
574 )?,
575 };
576
577 Some(InternalCall {
578 caller_symbol: call.caller_symbol.clone(),
579 file,
580 symbol,
581 line: call.line,
582 })
583}
584
585fn reexport_liveness_edges(
586 project_root: &Path,
587 file: &Path,
588 file_name: &str,
589 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
590 default_export_symbols_by_file: &BTreeMap<String, String>,
591) -> Vec<InternalCall> {
592 let Some(lang) = detect_language(file) else {
593 return Vec::new();
594 };
595 let Ok(source) = fs::read_to_string(file) else {
596 return Vec::new();
597 };
598
599 match lang {
600 LangId::TypeScript | LangId::Tsx | LangId::JavaScript => ts_reexport_liveness_edges(
601 project_root,
602 file,
603 file_name,
604 &source,
605 lang,
606 exported_symbols_by_file,
607 default_export_symbols_by_file,
608 ),
609 LangId::Rust => rust_reexport_liveness_edges(
610 project_root,
611 file,
612 file_name,
613 &source,
614 exported_symbols_by_file,
615 default_export_symbols_by_file,
616 ),
617 _ => Vec::new(),
618 }
619}
620
621fn ts_reexport_liveness_edges(
622 project_root: &Path,
623 file: &Path,
624 file_name: &str,
625 source: &str,
626 lang: LangId,
627 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
628 default_export_symbols_by_file: &BTreeMap<String, String>,
629) -> Vec<InternalCall> {
630 let grammar = grammar_for(lang);
631 let mut parser = tree_sitter::Parser::new();
632 if parser.set_language(&grammar).is_err() {
633 return Vec::new();
634 }
635 let Some(tree) = parser.parse(source, None) else {
636 return Vec::new();
637 };
638
639 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
640 let mut edges = Vec::new();
641 let mut cursor = tree.root_node().walk();
642 if !cursor.goto_first_child() {
643 return edges;
644 }
645
646 loop {
647 let node = cursor.node();
648 if node.kind() == "export_statement" {
649 if let Some(module_path) = export_source_module(source, node) {
650 if let Some(module_entry) = resolve_import_module_path(from_dir, &module_path) {
651 edges.extend(ts_reexport_edges_for_statement(
652 project_root,
653 file_name,
654 source,
655 node,
656 &module_entry,
657 exported_symbols_by_file,
658 default_export_symbols_by_file,
659 ));
660 }
661 }
662 }
663
664 if !cursor.goto_next_sibling() {
665 break;
666 }
667 }
668
669 edges
670}
671
672fn ts_reexport_edges_for_statement(
673 project_root: &Path,
674 file_name: &str,
675 source: &str,
676 node: tree_sitter::Node,
677 module_entry: &Path,
678 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
679 default_export_symbols_by_file: &BTreeMap<String, String>,
680) -> Vec<InternalCall> {
681 let mut edges = Vec::new();
682 let line = (node.start_position().row + 1) as u32;
683 let raw_export = node_text(source, node).trim();
684
685 for specifier in ts_reexport_specifiers(raw_export) {
686 if !file_exports_symbol(file_name, &specifier.exported, exported_symbols_by_file) {
687 continue;
688 }
689 if let Some((target_file, target_symbol)) = resolve_imported_export_liveness_root(
690 project_root,
691 module_entry,
692 &specifier.imported,
693 exported_symbols_by_file,
694 default_export_symbols_by_file,
695 ) {
696 edges.push(InternalCall {
697 caller_symbol: specifier.exported,
698 file: target_file,
699 symbol: target_symbol,
700 line,
701 });
702 }
703 }
704
705 if raw_export.contains('*') {
706 if let Some(namespace_export) = ts_namespace_reexport_name(raw_export) {
707 if file_exports_symbol(file_name, &namespace_export, exported_symbols_by_file) {
708 edges.extend(reexport_edges_for_all_target_symbols(
709 project_root,
710 file_name,
711 &namespace_export,
712 module_entry,
713 line,
714 exported_symbols_by_file,
715 default_export_symbols_by_file,
716 false,
717 ));
718 }
719 } else {
720 edges.extend(reexport_edges_for_all_target_symbols(
721 project_root,
722 file_name,
723 "",
724 module_entry,
725 line,
726 exported_symbols_by_file,
727 default_export_symbols_by_file,
728 true,
729 ));
730 }
731 }
732
733 edges
734}
735
736fn ts_reexport_specifiers(raw_export: &str) -> Vec<ReexportSpecifier> {
737 let Some(start) = raw_export.find('{').map(|index| index + 1) else {
738 return Vec::new();
739 };
740 let Some(end) = raw_export[start..].find('}').map(|index| start + index) else {
741 return Vec::new();
742 };
743
744 raw_export[start..end]
745 .split(',')
746 .filter_map(|specifier| {
747 let specifier = specifier.trim();
748 if specifier.is_empty() {
749 return None;
750 }
751 let imported = specifier_imported_name(specifier).trim();
752 let exported = specifier_local_name(specifier).trim();
753 if imported.is_empty() || exported.is_empty() {
754 return None;
755 }
756 Some(ReexportSpecifier {
757 imported: imported.to_string(),
758 exported: exported.to_string(),
759 })
760 })
761 .collect()
762}
763
764fn ts_namespace_reexport_name(raw_export: &str) -> Option<String> {
765 let after_star = raw_export.split_once('*')?.1.trim_start();
766 let after_as = after_star.strip_prefix("as")?.trim_start();
767 let name = after_as
768 .split_whitespace()
769 .next()?
770 .trim_matches(|ch: char| ch == '{' || ch == '}' || ch == ';' || ch == ',');
771 (!name.is_empty()).then(|| name.to_string())
772}
773
774fn rust_reexport_liveness_edges(
775 project_root: &Path,
776 file: &Path,
777 file_name: &str,
778 source: &str,
779 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
780 default_export_symbols_by_file: &BTreeMap<String, String>,
781) -> Vec<InternalCall> {
782 let module_files = rust_module_files(file, source);
783 let mut edges = Vec::new();
784
785 for (statement, line) in rust_pub_use_statements(source) {
786 for specifier in rust_reexport_specifiers(&statement) {
787 let Some(module_entry) = rust_module_entry(&module_files, &specifier.module_path)
788 else {
789 continue;
790 };
791
792 if specifier.imported == "*" {
793 edges.extend(reexport_edges_for_all_target_symbols(
794 project_root,
795 file_name,
796 "",
797 &module_entry,
798 line,
799 exported_symbols_by_file,
800 default_export_symbols_by_file,
801 true,
802 ));
803 continue;
804 }
805
806 if !file_exports_symbol(file_name, &specifier.exported, exported_symbols_by_file) {
807 continue;
808 }
809 if let Some((target_file, target_symbol)) = resolve_imported_export_liveness_root(
810 project_root,
811 &module_entry,
812 &specifier.imported,
813 exported_symbols_by_file,
814 default_export_symbols_by_file,
815 ) {
816 edges.push(InternalCall {
817 caller_symbol: specifier.exported,
818 file: target_file,
819 symbol: target_symbol,
820 line,
821 });
822 }
823 }
824 }
825
826 edges
827}
828
829fn reexport_edges_for_all_target_symbols(
830 project_root: &Path,
831 file_name: &str,
832 namespace_export: &str,
833 module_entry: &Path,
834 line: u32,
835 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
836 default_export_symbols_by_file: &BTreeMap<String, String>,
837 match_current_export_names: bool,
838) -> Vec<InternalCall> {
839 let Some((_, target_symbols)) =
840 exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
841 else {
842 return Vec::new();
843 };
844
845 let mut edges = Vec::new();
846 for target_symbol in target_symbols {
847 let caller_symbol = if match_current_export_names {
848 if !file_exports_symbol(file_name, target_symbol, exported_symbols_by_file) {
849 continue;
850 }
851 target_symbol.clone()
852 } else {
853 namespace_export.to_string()
854 };
855
856 if let Some((target_file, resolved_symbol)) = resolve_imported_export_liveness_root(
857 project_root,
858 module_entry,
859 target_symbol,
860 exported_symbols_by_file,
861 default_export_symbols_by_file,
862 ) {
863 edges.push(InternalCall {
864 caller_symbol,
865 file: target_file,
866 symbol: resolved_symbol,
867 line,
868 });
869 }
870 }
871
872 edges
873}
874
875fn rust_module_files(file: &Path, source: &str) -> BTreeMap<String, PathBuf> {
876 let base_dir = file.parent().unwrap_or_else(|| Path::new("."));
877 let mut modules = BTreeMap::new();
878 for line in source.lines() {
879 let trimmed = line.trim();
880 let after_visibility = trimmed
881 .strip_prefix("pub ")
882 .or_else(|| trimmed.strip_prefix("pub(crate) "))
883 .or_else(|| trimmed.strip_prefix("pub(super) "))
884 .unwrap_or(trimmed)
885 .trim_start();
886 let Some(after_mod) = after_visibility.strip_prefix("mod ") else {
887 continue;
888 };
889 let module = after_mod
890 .trim_end_matches(';')
891 .split_whitespace()
892 .next()
893 .unwrap_or_default()
894 .trim();
895 if module.is_empty() || module.contains('{') {
896 continue;
897 }
898 if let Some(path) = resolve_rust_module_file(base_dir, module) {
899 modules.insert(module.to_string(), path);
900 }
901 }
902 modules
903}
904
905fn resolve_rust_module_file(base_dir: &Path, module: &str) -> Option<PathBuf> {
906 let flat = base_dir.join(format!("{module}.rs"));
907 if flat.is_file() {
908 return Some(flat);
909 }
910 let nested = base_dir.join(module).join("mod.rs");
911 nested.is_file().then_some(nested)
912}
913
914fn rust_pub_use_statements(source: &str) -> Vec<(String, u32)> {
915 let mut statements = Vec::new();
916 let mut current = String::new();
917 let mut start_line = 0u32;
918
919 for (index, line) in source.lines().enumerate() {
920 let trimmed = line.trim();
921 if current.is_empty() {
922 if !(trimmed.starts_with("pub use ") || trimmed.starts_with("pub(crate) use ")) {
923 continue;
924 }
925 start_line = (index + 1) as u32;
926 }
927
928 current.push(' ');
929 current.push_str(trimmed);
930 if trimmed.ends_with(';') {
931 statements.push((current.trim().to_string(), start_line));
932 current.clear();
933 }
934 }
935
936 statements
937}
938
939fn rust_reexport_specifiers(statement: &str) -> Vec<RustReexportSpecifier> {
940 let statement = statement
941 .trim()
942 .trim_end_matches(';')
943 .strip_prefix("pub(crate) use ")
944 .or_else(|| {
945 statement
946 .trim()
947 .trim_end_matches(';')
948 .strip_prefix("pub use ")
949 })
950 .unwrap_or("")
951 .trim();
952 if statement.is_empty() {
953 return Vec::new();
954 }
955
956 if let Some((module_path, grouped)) = statement.split_once("::{") {
957 let grouped = grouped.trim_end_matches('}');
958 return grouped
959 .split(',')
960 .filter_map(|specifier| rust_reexport_specifier(module_path.trim(), specifier.trim()))
961 .collect();
962 }
963
964 let Some((module_path, imported)) = statement.rsplit_once("::") else {
965 return Vec::new();
966 };
967 rust_reexport_specifier(module_path.trim(), imported.trim())
968 .into_iter()
969 .collect()
970}
971
972fn rust_reexport_specifier(module_path: &str, specifier: &str) -> Option<RustReexportSpecifier> {
973 if specifier.is_empty() {
974 return None;
975 }
976 let (imported, exported) = specifier
977 .split_once(" as ")
978 .map(|(imported, exported)| (imported.trim(), exported.trim()))
979 .unwrap_or((specifier.trim(), specifier.trim()));
980 if imported.is_empty() || exported.is_empty() {
981 return None;
982 }
983 Some(RustReexportSpecifier {
984 module_path: rust_normalize_module_path(module_path),
985 imported: imported.to_string(),
986 exported: exported.to_string(),
987 })
988}
989
990fn rust_normalize_module_path(module_path: &str) -> Vec<String> {
991 module_path
992 .split("::")
993 .filter_map(|segment| {
994 let segment = segment.trim();
995 if segment.is_empty() || matches!(segment, "self" | "crate") {
996 None
997 } else {
998 Some(segment.to_string())
999 }
1000 })
1001 .collect()
1002}
1003
1004fn rust_module_entry(
1005 module_files: &BTreeMap<String, PathBuf>,
1006 module_path: &[String],
1007) -> Option<PathBuf> {
1008 let first = module_path.first()?;
1009 module_files.get(first).cloned()
1010}
1011
1012fn file_exports_symbol(
1013 file_name: &str,
1014 symbol: &str,
1015 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1016) -> bool {
1017 exported_symbols_by_file
1018 .get(file_name)
1019 .is_some_and(|symbols| symbols.contains(symbol))
1020}
1021
1022fn export_source_module(source: &str, node: tree_sitter::Node) -> Option<String> {
1023 node.child_by_field_name("source")
1024 .or_else(|| find_child_by_kind(node, "string"))
1025 .and_then(|source_node| string_literal_content(source, source_node))
1026}
1027
1028fn find_child_by_kind<'tree>(
1029 node: tree_sitter::Node<'tree>,
1030 kind: &str,
1031) -> Option<tree_sitter::Node<'tree>> {
1032 let mut cursor = node.walk();
1033 if !cursor.goto_first_child() {
1034 return None;
1035 }
1036 loop {
1037 let child = cursor.node();
1038 if child.kind() == kind {
1039 return Some(child);
1040 }
1041 if let Some(descendant) = find_child_by_kind(child, kind) {
1042 return Some(descendant);
1043 }
1044 if !cursor.goto_next_sibling() {
1045 break;
1046 }
1047 }
1048 None
1049}
1050
1051fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
1052 let raw = node_text(source, node).trim();
1053 let quote = raw.chars().next()?;
1054 if quote != '\'' && quote != '"' {
1055 return None;
1056 }
1057 raw.strip_prefix(quote)
1058 .and_then(|value| value.strip_suffix(quote))
1059 .map(ToOwned::to_owned)
1060}
1061
1062fn node_text<'a>(source: &'a str, node: tree_sitter::Node) -> &'a str {
1063 &source[node.byte_range()]
1064}
1065
1066fn resolve_import_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1067 if is_relative_module_path(module_path) {
1068 return resolve_js_ts_module_path(from_dir, module_path);
1069 }
1070 resolve_workspace_package_import(from_dir, module_path)
1071}
1072
1073fn resolve_js_ts_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1074 resolve_module_path(from_dir, module_path)
1075 .or_else(|| resolve_esm_source_module_path(from_dir, module_path))
1076}
1077
1078fn resolve_esm_source_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1079 if !is_relative_module_path(module_path) {
1080 return None;
1081 }
1082 let base = from_dir.join(module_path);
1083 let ext = base.extension().and_then(|extension| extension.to_str())?;
1084 let candidates: &[&str] = match ext {
1085 "js" => &["ts", "tsx"],
1086 "jsx" => &["tsx", "ts"],
1087 "mjs" => &["mts", "ts"],
1088 "cjs" => &["cts", "ts"],
1089 _ => return None,
1090 };
1091
1092 candidates
1093 .iter()
1094 .map(|extension| base.with_extension(extension))
1095 .find(|candidate| candidate.is_file())
1096}
1097
1098fn is_relative_module_path(module_path: &str) -> bool {
1099 module_path.starts_with("./")
1100 || module_path.starts_with("../")
1101 || module_path == "."
1102 || module_path == ".."
1103}
1104
1105#[derive(Debug)]
1106struct ReexportSpecifier {
1107 imported: String,
1108 exported: String,
1109}
1110
1111#[derive(Debug)]
1112struct RustReexportSpecifier {
1113 module_path: Vec<String>,
1114 imported: String,
1115 exported: String,
1116}
1117
1118fn imported_export_liveness_roots(
1119 project_root: &Path,
1120 file: &Path,
1121 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1122 default_export_symbols_by_file: &BTreeMap<String, String>,
1123) -> ImportedExportLiveness {
1124 let Some(lang) = detect_language(file)
1125 .filter(|lang| matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript))
1126 else {
1127 return ImportedExportLiveness::default();
1128 };
1129 let Ok(source) = fs::read_to_string(file) else {
1130 return ImportedExportLiveness::default();
1131 };
1132 let grammar = grammar_for(lang);
1133 let mut parser = tree_sitter::Parser::new();
1134 if parser.set_language(&grammar).is_err() {
1135 return ImportedExportLiveness::default();
1136 }
1137 let Some(tree) = parser.parse(&source, None) else {
1138 return ImportedExportLiveness::default();
1139 };
1140
1141 let import_block = parse_imports(&source, &tree, lang);
1142 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1143 let mut root_exports: BTreeSet<ExportNode> = BTreeSet::new();
1144 let mut namespace_exports: BTreeSet<ExportNode> = BTreeSet::new();
1145
1146 for import in &import_block.imports {
1147 if import.namespace_import.is_some() {
1148 if let Some(module_entry) = resolve_import_module_path(from_dir, &import.module_path) {
1149 namespace_exports.extend(resolve_namespace_import_liveness_roots(
1150 project_root,
1151 &module_entry,
1152 exported_symbols_by_file,
1153 default_export_symbols_by_file,
1154 ));
1155 }
1156 }
1157
1158 let Some(module_entry) = resolve_import_module_path(from_dir, &import.module_path) else {
1159 continue;
1160 };
1161
1162 for imported_name in import
1163 .names
1164 .iter()
1165 .map(|name| specifier_imported_name(name))
1166 {
1167 if let Some(root) = resolve_imported_export_liveness_root(
1168 project_root,
1169 &module_entry,
1170 imported_name,
1171 exported_symbols_by_file,
1172 default_export_symbols_by_file,
1173 ) {
1174 root_exports.insert(root);
1175 }
1176 }
1177
1178 if import.default_import.is_some() {
1179 if let Some(root) = resolve_imported_export_liveness_root(
1180 project_root,
1181 &module_entry,
1182 "default",
1183 exported_symbols_by_file,
1184 default_export_symbols_by_file,
1185 ) {
1186 root_exports.insert(root);
1187 }
1188 }
1189 }
1190
1191 ImportedExportLiveness {
1192 root_exports: root_exports
1193 .into_iter()
1194 .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1195 .collect(),
1196 namespace_exports: namespace_exports
1197 .into_iter()
1198 .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1199 .collect(),
1200 }
1201}
1202
1203fn resolve_workspace_package_import(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1204 let package_name = package_name_from_import(module_path)?;
1205 let module_entry = resolve_module_path(from_dir, module_path)?;
1206 let resolved_package_name = package_name_for_file(&module_entry)?;
1207 (resolved_package_name == package_name).then_some(module_entry)
1208}
1209
1210fn package_name_from_import(module_path: &str) -> Option<String> {
1211 if module_path.starts_with('.') || module_path.starts_with('/') || module_path.starts_with('#')
1212 {
1213 return None;
1214 }
1215
1216 let mut parts = module_path.split('/');
1217 let first = parts.next()?;
1218 if first.is_empty() {
1219 return None;
1220 }
1221
1222 if first.starts_with('@') {
1223 let second = parts.next()?;
1224 (!second.is_empty()).then(|| format!("{first}/{second}"))
1225 } else {
1226 Some(first.to_string())
1227 }
1228}
1229
1230fn package_name_for_file(file: &Path) -> Option<String> {
1231 let mut current = file.parent();
1232 while let Some(dir) = current {
1233 let manifest = dir.join("package.json");
1234 if manifest.is_file() {
1235 if let Ok(source) = fs::read_to_string(&manifest) {
1236 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) {
1237 if let Some(name) = value.get("name").and_then(serde_json::Value::as_str) {
1238 return Some(name.to_string());
1239 }
1240 }
1241 }
1242 }
1243 current = dir.parent();
1244 }
1245 None
1246}
1247
1248fn resolve_namespace_import_liveness_roots(
1249 project_root: &Path,
1250 module_entry: &Path,
1251 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1252 default_export_symbols_by_file: &BTreeMap<String, String>,
1253) -> Vec<ExportNode> {
1254 let Some((_, symbols)) =
1255 exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
1256 else {
1257 return Vec::new();
1258 };
1259 let mut roots = BTreeSet::new();
1260
1261 for symbol in symbols {
1262 if let Some(root) = resolve_imported_export_liveness_root(
1263 project_root,
1264 module_entry,
1265 symbol,
1266 exported_symbols_by_file,
1267 default_export_symbols_by_file,
1268 ) {
1269 roots.insert(root);
1270 }
1271 }
1272
1273 if default_export_symbol_for_resolved_file(
1274 project_root,
1275 module_entry,
1276 default_export_symbols_by_file,
1277 )
1278 .is_some()
1279 {
1280 if let Some(root) = resolve_imported_export_liveness_root(
1281 project_root,
1282 module_entry,
1283 "default",
1284 exported_symbols_by_file,
1285 default_export_symbols_by_file,
1286 ) {
1287 roots.insert(root);
1288 }
1289 }
1290
1291 roots.into_iter().collect()
1292}
1293
1294fn resolve_imported_export_liveness_root(
1295 project_root: &Path,
1296 module_entry: &Path,
1297 imported_symbol: &str,
1298 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1299 default_export_symbols_by_file: &BTreeMap<String, String>,
1300) -> Option<ExportNode> {
1301 let mut file_exports_symbol = |path: &Path, symbol_name: &str| {
1302 exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1303 .is_some_and(|(_, symbols)| symbols.contains(symbol_name))
1304 };
1305 let mut file_default_export_symbol = |path: &Path| {
1306 default_export_symbol_for_resolved_file(project_root, path, default_export_symbols_by_file)
1307 .or_else(|| {
1308 exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1309 .and_then(|(_, symbols)| {
1310 symbols.contains("default").then(|| "default".to_string())
1311 })
1312 })
1313 };
1314
1315 let (target_file, symbol) = resolve_reexported_symbol_target(
1316 module_entry,
1317 imported_symbol,
1318 &mut file_exports_symbol,
1319 &mut file_default_export_symbol,
1320 )?;
1321
1322 let (file, symbols) =
1323 exported_symbols_for_resolved_file(project_root, &target_file, exported_symbols_by_file)?;
1324 symbols.contains(&symbol).then_some((file, symbol))
1325}
1326
1327fn exported_symbols_for_resolved_file<'a>(
1328 project_root: &Path,
1329 file: &Path,
1330 exported_symbols_by_file: &'a BTreeMap<String, BTreeSet<String>>,
1331) -> Option<(String, &'a BTreeSet<String>)> {
1332 let relative = relative_path(project_root, file);
1333 if let Some(symbols) = exported_symbols_by_file.get(&relative) {
1334 return Some((relative, symbols));
1335 }
1336
1337 let canonical_root = fs::canonicalize(project_root).ok()?;
1338 let canonical_file = fs::canonicalize(file).ok()?;
1339 let relative = relative_path(&canonical_root, &canonical_file);
1340 exported_symbols_by_file
1341 .get(&relative)
1342 .map(|symbols| (relative, symbols))
1343}
1344
1345fn default_export_symbol_for_resolved_file(
1346 project_root: &Path,
1347 file: &Path,
1348 default_export_symbols_by_file: &BTreeMap<String, String>,
1349) -> Option<String> {
1350 let relative = relative_path(project_root, file);
1351 if let Some(symbol) = default_export_symbols_by_file.get(&relative) {
1352 return Some(symbol.clone());
1353 }
1354
1355 let canonical_root = fs::canonicalize(project_root).ok()?;
1356 let canonical_file = fs::canonicalize(file).ok()?;
1357 let relative = relative_path(&canonical_root, &canonical_file);
1358 default_export_symbols_by_file.get(&relative).cloned()
1359}
1360
1361fn resolve_unqualified_target(
1362 caller_file: &str,
1363 symbol: &str,
1364 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1365 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1366) -> Option<String> {
1367 if exported_symbols_by_file
1368 .get(caller_file)
1369 .is_some_and(|symbols| symbols.contains(symbol))
1370 {
1371 return Some(caller_file.to_string());
1372 }
1373
1374 let files = files_by_exported_symbol.get(symbol)?;
1375 if files.len() == 1 {
1376 files.iter().next().cloned()
1377 } else {
1378 None
1379 }
1380}
1381
1382fn dispatched_method_name_from_call(call: &CallgraphOutboundCall) -> Option<String> {
1383 let (target, full_callee) = split_call_target_metadata(&call.target);
1384 if let Some(full_callee) = full_callee {
1385 return dispatched_method_name_from_callee(full_callee);
1386 }
1387 if target.contains("::") || target.contains('#') {
1388 return None;
1389 }
1390 dispatched_method_name_from_callee(target)
1391}
1392
1393fn dispatched_method_name_from_callee(callee: &str) -> Option<String> {
1394 let callee = callee.trim();
1395 if !callee.contains('.') {
1396 return None;
1397 }
1398
1399 clean_symbol(callee.rsplit('.').next()?.trim().trim_start_matches('?'))
1400}
1401
1402fn split_call_target_metadata(target: &str) -> (&str, Option<&str>) {
1403 target
1404 .split_once(DISPATCHED_CALLEE_SEPARATOR)
1405 .map_or((target, None), |(target, full_callee)| {
1406 (target, Some(full_callee))
1407 })
1408}
1409
1410fn symbol_liveness_name(symbol: &str) -> &str {
1411 symbol
1412 .rsplit(['.', ':', '#'])
1413 .find(|segment| !segment.is_empty())
1414 .unwrap_or(symbol)
1415}
1416
1417fn is_type_like_kind(kind: &str) -> bool {
1418 matches!(
1419 kind,
1420 "struct" | "enum" | "trait" | "type" | "type_alias" | "interface"
1421 )
1422}
1423
1424fn parse_target(project_root: &Path, target: &str) -> ParsedTarget {
1425 let (target, _) = split_call_target_metadata(target);
1426 let trimmed = target.trim();
1427 if trimmed.is_empty() {
1428 return ParsedTarget {
1429 file: None,
1430 symbol: None,
1431 };
1432 }
1433
1434 if let Some((file, symbol)) = split_file_symbol_target(project_root, trimmed, "::") {
1435 return ParsedTarget {
1436 file: Some(relative_path(project_root, Path::new(file))),
1437 symbol: clean_symbol(symbol),
1438 };
1439 }
1440
1441 if let Some((file, symbol)) = trimmed.rsplit_once('#') {
1442 return ParsedTarget {
1443 file: Some(relative_path(project_root, Path::new(file))),
1444 symbol: clean_symbol(symbol),
1445 };
1446 }
1447
1448 ParsedTarget {
1449 file: None,
1450 symbol: clean_symbol(trimmed),
1451 }
1452}
1453
1454fn split_file_symbol_target<'a>(
1455 project_root: &Path,
1456 target: &'a str,
1457 separator: &str,
1458) -> Option<(&'a str, &'a str)> {
1459 let mut search_start = 0;
1460 while let Some(offset) = target[search_start..].find(separator) {
1461 let split_at = search_start + offset;
1462 let file = &target[..split_at];
1463 let symbol = &target[split_at + separator.len()..];
1464 if !symbol.trim().is_empty() && looks_like_source_file_target(project_root, file) {
1465 return Some((file, symbol));
1466 }
1467 search_start = split_at + separator.len();
1468 }
1469 None
1470}
1471
1472fn looks_like_source_file_target(project_root: &Path, file: &str) -> bool {
1473 let path = Path::new(file);
1474 language_for_file(file) != "unknown" || path.is_file() || project_root.join(path).is_file()
1475}
1476
1477fn clean_symbol(symbol: &str) -> Option<String> {
1478 let trimmed = symbol.trim();
1479 if trimmed.is_empty() {
1480 None
1481 } else {
1482 Some(trimmed.to_string())
1483 }
1484}
1485
1486fn liveness_roots_for_file(
1487 file_name: &str,
1488 exports: &[ExportContribution],
1489 internal_calls: &[InternalCall],
1490 is_liveness_root_file: bool,
1491 is_public_api_file: bool,
1492) -> Vec<String> {
1493 if !is_liveness_root_file && !is_public_api_file {
1494 return Vec::new();
1495 }
1496
1497 let mut roots = BTreeSet::new();
1498 roots.insert("<top-level>".to_string());
1499 if is_public_api_file {
1500 roots.extend(exports.iter().map(|export| export.symbol.clone()));
1501 } else {
1502 roots.extend(
1503 exports
1504 .iter()
1505 .filter(|export| is_explicit_liveness_symbol(file_name, &export.symbol))
1506 .map(|export| export.symbol.clone()),
1507 );
1508 roots.extend(
1509 internal_calls
1510 .iter()
1511 .map(|call| call.caller_symbol.as_str())
1512 .filter(|symbol| is_explicit_liveness_symbol(file_name, symbol))
1513 .map(str::to_string),
1514 );
1515 }
1516
1517 roots.into_iter().collect()
1518}
1519
1520fn is_explicit_liveness_symbol(file_name: &str, symbol: &str) -> bool {
1521 let symbol = symbol.rsplit("::").next().unwrap_or(symbol);
1522 if symbol == "<top-level>" {
1523 return true;
1524 }
1525
1526 let lower = symbol.to_ascii_lowercase();
1527 if matches!(
1528 lower.as_str(),
1529 "main" | "init" | "setup" | "bootstrap" | "run"
1530 ) {
1531 return true;
1532 }
1533
1534 Path::new(file_name)
1535 .file_stem()
1536 .and_then(|stem| stem.to_str())
1537 .is_some_and(|stem| stem == symbol)
1538}
1539
1540pub(crate) fn collect_public_api_files(project_root: &Path) -> BTreeSet<String> {
1541 crate::inspect::entry_points::resolve_entry_points(project_root)
1542 .public_api_files_relative(project_root)
1543}
1544
1545fn language_for_file(file: &str) -> &'static str {
1546 let extension = Path::new(file)
1547 .extension()
1548 .and_then(|extension| extension.to_str())
1549 .map(|extension| extension.to_ascii_lowercase())
1550 .unwrap_or_default();
1551
1552 match extension.as_str() {
1553 "rs" => "rust",
1554 "ts" | "tsx" | "mts" | "cts" => "typescript",
1555 "js" | "jsx" | "mjs" | "cjs" => "javascript",
1556 "py" => "python",
1557 "go" => "go",
1558 "c" | "h" => "c",
1559 "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
1560 "zig" => "zig",
1561 "cs" => "csharp",
1562 "sh" | "bash" | "zsh" | "fish" => "bash",
1563 "html" | "htm" => "html",
1564 "md" | "markdown" => "markdown",
1565 "sol" => "solidity",
1566 "vue" => "vue",
1567 "json" => "json",
1568 "scala" => "scala",
1569 "java" => "java",
1570 "rb" => "ruby",
1571 "kt" | "kts" => "kotlin",
1572 "swift" => "swift",
1573 "php" => "php",
1574 "lua" => "lua",
1575 "pl" | "pm" => "perl",
1576 _ => "unknown",
1577 }
1578}
1579
1580fn collect_type_ref_names(file: &Path) -> BTreeSet<String> {
1581 let Some(lang) = detect_language(file).filter(|lang| supports_type_refs(*lang)) else {
1582 return BTreeSet::new();
1583 };
1584 let Ok(source) = fs::read_to_string(file) else {
1585 return BTreeSet::new();
1586 };
1587 let grammar = grammar_for(lang);
1588 let mut parser = tree_sitter::Parser::new();
1589 if parser.set_language(&grammar).is_err() {
1590 return BTreeSet::new();
1591 }
1592 let Some(tree) = parser.parse(&source, None) else {
1593 return BTreeSet::new();
1594 };
1595
1596 extract_type_references(&source, tree.root_node(), lang)
1597}
1598
1599fn supports_type_refs(lang: LangId) -> bool {
1600 matches!(
1601 lang,
1602 LangId::TypeScript
1603 | LangId::Tsx
1604 | LangId::JavaScript
1605 | LangId::Python
1606 | LangId::Rust
1607 | LangId::Go
1608 )
1609}
1610
1611fn collect_freshness(file: &Path) -> FileFreshness {
1612 cache_freshness::collect(file).unwrap_or_else(|_| FileFreshness {
1613 mtime: UNIX_EPOCH,
1614 size: 0,
1615 content_hash: cache_freshness::zero_hash(),
1616 })
1617}
1618
1619fn same_file(project_root: &Path, left: &Path, right: &Path) -> bool {
1620 normalize_absolute(project_root, left) == normalize_absolute(project_root, right)
1621}
1622
1623fn relative_path(project_root: &Path, path: &Path) -> String {
1624 let absolute = if path.is_absolute() {
1625 path.to_path_buf()
1626 } else {
1627 project_root.join(path)
1628 };
1629 let normalized = normalize_path(&absolute);
1630 normalized
1631 .strip_prefix(&normalize_path(project_root))
1632 .unwrap_or(normalized.as_path())
1633 .to_string_lossy()
1634 .replace('\\', "/")
1635}
1636
1637fn normalize_absolute(project_root: &Path, path: &Path) -> PathBuf {
1638 let absolute = if path.is_absolute() {
1639 path.to_path_buf()
1640 } else {
1641 project_root.join(path)
1642 };
1643 normalize_path(&absolute)
1644}
1645
1646fn normalize_path(path: &Path) -> PathBuf {
1647 let mut normalized = PathBuf::new();
1648 for component in path.components() {
1649 match component {
1650 Component::CurDir => {}
1651 Component::ParentDir => {
1652 if !normalized.pop() {
1653 normalized.push(component.as_os_str());
1654 }
1655 }
1656 _ => normalized.push(component.as_os_str()),
1657 }
1658 }
1659 normalized
1660}
1661
1662#[derive(Debug, Clone, Deserialize)]
1663struct DeadCodeContribution {
1664 file: String,
1665 exports: Vec<ExportContribution>,
1666 internal_calls: Vec<InternalCallContribution>,
1667 #[serde(default)]
1668 liveness_roots: Vec<String>,
1669 #[serde(default)]
1670 imported_exports: Vec<ImportedExportContribution>,
1671 #[serde(default)]
1672 namespace_imported_exports: Vec<ImportedExportContribution>,
1673 #[serde(default)]
1674 dispatched_method_names: Vec<String>,
1675 #[serde(default)]
1676 type_ref_names: Vec<String>,
1677}
1678
1679#[derive(Debug, Clone, Deserialize)]
1680struct ImportedExportContribution {
1681 file: String,
1682 symbol: String,
1683}
1684
1685#[derive(Debug, Clone, Deserialize)]
1686struct ExportContribution {
1687 symbol: String,
1688 kind: String,
1689 line: u32,
1690 #[serde(default)]
1691 is_type_like: bool,
1692 #[serde(default)]
1693 is_entry_point: bool,
1694}
1695
1696#[derive(Debug, Clone, Deserialize)]
1697struct InternalCallContribution {
1698 #[serde(default)]
1699 caller_symbol: String,
1700 file: String,
1701 symbol: String,
1702}
1703
1704#[derive(Debug, Clone)]
1705struct InternalCall {
1706 caller_symbol: String,
1707 file: String,
1708 symbol: String,
1709 line: u32,
1710}
1711
1712#[derive(Debug, Clone)]
1713struct ParsedTarget {
1714 file: Option<String>,
1715 symbol: Option<String>,
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use super::*;
1721 use std::fs;
1722 use std::path::{Path, PathBuf};
1723 use std::sync::{Arc, RwLock};
1724
1725 use crate::config::Config;
1726 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
1727 use crate::inspect::{CallgraphExport, JobKey};
1728 use crate::parser::SymbolCache;
1729
1730 fn fixture_project(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
1731 let temp_dir = tempfile::tempdir().expect("tempdir");
1732 let root = temp_dir.path().join("project");
1733 fs::create_dir_all(&root).expect("create project root");
1734
1735 let paths = files
1736 .iter()
1737 .map(|(relative, contents)| {
1738 let path = root.join(relative);
1739 if let Some(parent) = path.parent() {
1740 fs::create_dir_all(parent).expect("create parent");
1741 }
1742 fs::write(&path, contents).expect("write fixture file");
1743 path
1744 })
1745 .collect::<Vec<_>>();
1746
1747 (temp_dir, root, paths)
1748 }
1749
1750 fn job(root: &Path, scope_files: Vec<PathBuf>, snapshot: CallgraphSnapshot) -> InspectJob {
1751 InspectJob {
1752 job_id: 1,
1753 key: JobKey::for_project_category(InspectCategory::DeadCode),
1754 category: InspectCategory::DeadCode,
1755 scope_files,
1756 project_root: root.to_path_buf(),
1757 inspect_dir: root.join(".aft-cache").join("inspect"),
1758 config: Arc::new(Config {
1759 project_root: Some(root.to_path_buf()),
1760 ..Config::default()
1761 }),
1762 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
1763 callgraph_snapshot: Some(Arc::new(snapshot)),
1764 }
1765 }
1766
1767 fn snapshot(
1768 files: Vec<PathBuf>,
1769 exported_symbols: Vec<CallgraphExport>,
1770 outbound_calls: Vec<CallgraphOutboundCall>,
1771 ) -> CallgraphSnapshot {
1772 snapshot_with_entry_points(files, exported_symbols, outbound_calls, BTreeSet::new())
1773 }
1774
1775 fn snapshot_with_entry_points(
1776 files: Vec<PathBuf>,
1777 exported_symbols: Vec<CallgraphExport>,
1778 outbound_calls: Vec<CallgraphOutboundCall>,
1779 entry_points: BTreeSet<PathBuf>,
1780 ) -> CallgraphSnapshot {
1781 CallgraphSnapshot {
1782 generated_at: None,
1783 files,
1784 exported_symbols,
1785 outbound_calls,
1786 entry_points,
1787 }
1788 }
1789
1790 fn export(root: &Path, file: &str, symbol: &str, kind: &str) -> CallgraphExport {
1791 CallgraphExport {
1792 file: root.join(file),
1793 symbol: symbol.to_string(),
1794 kind: kind.to_string(),
1795 line: 1,
1796 }
1797 }
1798
1799 fn outbound(
1800 root: &Path,
1801 caller_file: &str,
1802 caller_symbol: &str,
1803 target: &str,
1804 ) -> CallgraphOutboundCall {
1805 CallgraphOutboundCall {
1806 caller_file: root.join(caller_file),
1807 caller_symbol: caller_symbol.to_string(),
1808 target: target.to_string(),
1809 line: 1,
1810 }
1811 }
1812
1813 fn dispatched_target(target: &str, full_callee: &str) -> String {
1814 format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{full_callee}")
1815 }
1816
1817 fn scan(job: InspectJob) -> serde_json::Value {
1818 run_dead_code_scan(&job)
1819 .outcome
1820 .expect("scan succeeds")
1821 .aggregate
1822 }
1823
1824 #[test]
1825 fn method_dispatched_by_receiver_call_is_live() {
1826 let (_temp_dir, root, paths) = fixture_project(&[
1827 ("src/service.ts", "export class Service { render() {} }\n"),
1828 (
1829 "src/consumer.ts",
1830 "function run(service: Service) { service.render(); }\n",
1831 ),
1832 ]);
1833 let aggregate = scan(job(
1834 &root,
1835 paths.clone(),
1836 snapshot(
1837 paths,
1838 vec![export(&root, "src/service.ts", "render", "method")],
1839 vec![outbound(
1840 &root,
1841 "src/consumer.ts",
1842 "run",
1843 &dispatched_target("render", "service.render"),
1844 )],
1845 ),
1846 ));
1847
1848 assert_eq!(aggregate["count"], 0);
1849 assert_eq!(aggregate["uncertain_count"], 0);
1850 assert!(aggregate["items"].as_array().unwrap().is_empty());
1851 }
1852
1853 #[test]
1854 fn method_without_any_dispatch_is_still_dead() {
1855 let (_temp_dir, root, paths) =
1856 fixture_project(&[("src/service.ts", "export class Service { render() {} }\n")]);
1857 let aggregate = scan(job(
1858 &root,
1859 paths.clone(),
1860 snapshot(
1861 paths,
1862 vec![export(&root, "src/service.ts", "render", "method")],
1863 Vec::new(),
1864 ),
1865 ));
1866
1867 assert_eq!(aggregate["count"], 1);
1868 assert_eq!(aggregate["items"][0]["symbol"], "render");
1869 assert_eq!(aggregate["uncertain_count"], 0);
1870 }
1871
1872 #[test]
1873 fn free_function_called_from_dispatch_live_method_body_is_live() {
1874 let (_temp_dir, root, paths) = fixture_project(&[
1885 (
1886 "src/service.ts",
1887 "export class Service { render() { helper(); } }\n",
1888 ),
1889 ("src/helper.ts", "export function helper() {}\n"),
1890 (
1891 "src/consumer.ts",
1892 "function run(service: Service) { service.render(); }\n",
1893 ),
1894 ]);
1895 let helper_target = format!("{}::helper", root.join("src/helper.ts").display());
1896 let aggregate = scan(job(
1897 &root,
1898 paths.clone(),
1899 snapshot(
1900 paths,
1901 vec![
1902 export(&root, "src/service.ts", "render", "method"),
1903 export(&root, "src/helper.ts", "helper", "function"),
1904 ],
1905 vec![
1906 outbound(
1909 &root,
1910 "src/consumer.ts",
1911 "run",
1912 &dispatched_target("render", "service.render"),
1913 ),
1914 outbound(&root, "src/service.ts", "Service::render", &helper_target),
1918 ],
1919 ),
1920 ));
1921
1922 assert_eq!(
1923 aggregate["count"], 0,
1924 "free function reached via dispatch-live method body must be live: {aggregate:#}"
1925 );
1926 assert!(aggregate["items"].as_array().unwrap().is_empty());
1927 }
1928
1929 #[test]
1930 fn rust_struct_referenced_only_in_types_is_live() {
1931 let (_temp_dir, root, paths) = fixture_project(&[
1932 ("src/types.rs", "pub struct Widget { id: u64 }\n"),
1933 (
1934 "src/main.rs",
1935 "use crate::types::Widget;\nstruct Holder { value: Widget }\npub fn main(input: Widget) -> Widget { input }\n",
1936 ),
1937 ]);
1938 let aggregate = scan(job(
1939 &root,
1940 paths.clone(),
1941 snapshot_with_entry_points(
1942 paths,
1943 vec![
1944 export(&root, "src/types.rs", "Widget", "struct"),
1945 export(&root, "src/main.rs", "main", "function"),
1946 ],
1947 Vec::new(),
1948 BTreeSet::from([root.join("src/main.rs")]),
1949 ),
1950 ));
1951
1952 assert_eq!(aggregate["count"], 0);
1953 assert_eq!(aggregate["uncertain_count"], 0);
1954 assert!(aggregate["items"].as_array().unwrap().is_empty());
1955 }
1956
1957 #[test]
1958 fn ts_interface_referenced_only_in_type_annotation_is_live() {
1959 let (_temp_dir, root, paths) = fixture_project(&[
1960 ("src/types.ts", "export interface Widget { id: string }\n"),
1961 (
1962 "src/main.ts",
1963 "import type { Widget } from './types';\nexport function run(input: Widget): void {}\n",
1964 ),
1965 ]);
1966 let aggregate = scan(job(
1967 &root,
1968 paths.clone(),
1969 snapshot_with_entry_points(
1970 paths,
1971 vec![
1972 export(&root, "src/types.ts", "Widget", "interface"),
1973 export(&root, "src/main.ts", "run", "function"),
1974 ],
1975 Vec::new(),
1976 BTreeSet::from([root.join("src/main.ts")]),
1977 ),
1978 ));
1979
1980 assert_eq!(aggregate["count"], 0);
1981 assert_eq!(aggregate["uncertain_count"], 0);
1982 assert!(aggregate["items"].as_array().unwrap().is_empty());
1983 }
1984
1985 #[test]
1986 fn type_like_export_without_call_or_type_ref_is_precise_dead() {
1987 let (_temp_dir, root, paths) =
1988 fixture_project(&[("src/types.ts", "export interface Widget { id: string }\n")]);
1989 let aggregate = scan(job(
1990 &root,
1991 paths.clone(),
1992 snapshot(
1993 paths,
1994 vec![export(&root, "src/types.ts", "Widget", "interface")],
1995 Vec::new(),
1996 ),
1997 ));
1998
1999 assert_eq!(aggregate["count"], 1);
2000 assert_eq!(aggregate["items"][0]["symbol"], "Widget");
2001 assert_eq!(aggregate["uncertain_count"], 0);
2002 assert!(aggregate["uncertain_items"].as_array().unwrap().is_empty());
2003 }
2004
2005 #[test]
2006 fn genuinely_unreachable_function_is_still_dead() {
2007 let (_temp_dir, root, paths) =
2008 fixture_project(&[("src/build.ts", "export function build() {}\n")]);
2009 let aggregate = scan(job(
2010 &root,
2011 paths.clone(),
2012 snapshot(
2013 paths,
2014 vec![export(&root, "src/build.ts", "build", "function")],
2015 Vec::new(),
2016 ),
2017 ));
2018
2019 assert_eq!(aggregate["count"], 1);
2020 assert_eq!(aggregate["items"][0]["symbol"], "build");
2021 assert_eq!(aggregate["uncertain_count"], 0);
2022 }
2023}