1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use serde::Serialize;
14use serde_json::Value;
15use tree_sitter::{Node, Parser};
16
17#[cfg(test)]
18use crate::calls::{call_node_kinds, extract_callee_name, extract_full_callee};
19use crate::calls::{extract_calls_full, extract_rust_value_references};
20#[cfg(test)]
21use crate::edit::line_col_to_byte;
22use crate::error::AftError;
23use crate::imports::{self, ImportBlock};
24use crate::parser::{detect_language, grammar_for, LangId};
25use crate::symbols::{Range, Symbol, SymbolKind};
26
27type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
32type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
33type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36 LazyLock::new(|| RwLock::new(HashMap::new()));
37static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
38 LazyLock::new(|| RwLock::new(HashMap::new()));
39static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
40 LazyLock::new(|| RwLock::new(HashMap::new()));
41
42const TOP_LEVEL_SYMBOL: &str = "<top-level>";
43const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
44const JS_TS_INDEX_FILES: &[&str] = &[
45 "index.ts",
46 "index.tsx",
47 "index.mts",
48 "index.cts",
49 "index.js",
50 "index.jsx",
51 "index.mjs",
52 "index.cjs",
53];
54
55fn symbol_identity(symbol: &Symbol) -> String {
56 if symbol.scope_chain.is_empty() {
57 symbol.name.clone()
58 } else {
59 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
60 }
61}
62
63fn symbol_unqualified_name(symbol: &str) -> &str {
64 symbol.rsplit("::").next().unwrap_or(symbol)
65}
66
67pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
68 full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
69}
70
71fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
72 let mut seen = HashSet::new();
73 let mut candidates = Vec::new();
74 let qualified_query = symbol_name.contains("::");
75
76 let mut consider = |candidate: &str| {
77 let matches = if qualified_query {
78 candidate == symbol_name
79 } else {
80 candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
81 };
82
83 if matches && seen.insert(candidate.to_string()) {
84 candidates.push(candidate.to_string());
85 }
86 };
87
88 for candidate in file_data.symbol_metadata.keys() {
89 consider(candidate);
90 }
91 for candidate in file_data.calls_by_symbol.keys() {
92 consider(candidate);
93 }
94 for candidate in &file_data.exported_symbols {
95 consider(candidate);
96 }
97
98 candidates.sort();
99 candidates
100}
101
102pub(crate) fn resolve_symbol_query_in_data(
103 file_data: &FileCallData,
104 file: &Path,
105 symbol_name: &str,
106) -> Result<String, AftError> {
107 let candidates = symbol_query_candidates(file_data, symbol_name);
108 match candidates.as_slice() {
109 [candidate] => Ok(candidate.clone()),
110 [] => Err(AftError::SymbolNotFound {
111 name: symbol_name.to_string(),
112 file: file.display().to_string(),
113 }),
114 _ => Err(AftError::AmbiguousSymbol {
115 name: symbol_name.to_string(),
116 candidates,
117 }),
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CallSite {
124 pub callee_name: String,
126 pub full_callee: String,
128 pub line: u32,
130 pub byte_start: usize,
132 pub byte_end: usize,
133}
134
135#[derive(Debug, Clone, Serialize)]
137pub struct SymbolMeta {
138 pub kind: SymbolKind,
140 pub exported: bool,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub signature: Option<String>,
145 pub line: u32,
147 pub range: Range,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub entry_point_attribute: Option<String>,
152}
153
154#[derive(Debug, Clone)]
157pub struct FileCallData {
158 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
160 pub value_refs_by_symbol: HashMap<String, Vec<CallSite>>,
163 pub exported_symbols: Vec<String>,
165 pub symbol_metadata: HashMap<String, SymbolMeta>,
167 pub default_export_symbol: Option<String>,
169 pub import_block: ImportBlock,
171 pub lang: LangId,
173}
174
175impl FileCallData {
176 pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
189 if let Some(meta) = self.symbol_metadata.get(name) {
190 return Some(meta);
191 }
192 self.symbol_metadata
193 .iter()
194 .find(|(key, _)| symbol_unqualified_name(key) == name)
195 .map(|(_, meta)| meta)
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum EdgeResolution {
202 Resolved { file: PathBuf, symbol: String },
204 Unresolved { callee_name: String },
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209struct ResolvedSymbol {
210 file: PathBuf,
211 symbol: String,
212}
213
214#[derive(Debug, Clone)]
215struct RustCrateInfo {
216 lib_name: String,
217 lib_root: Option<PathBuf>,
218 main_root: Option<PathBuf>,
219}
220
221#[derive(Debug, Clone)]
222struct RustModuleBase {
223 src_dir: PathBuf,
224 root_file: PathBuf,
225}
226
227#[derive(Debug, Clone)]
228struct RustUseEntry {
229 module_path: String,
230 local_name: String,
231 kind: RustUseKind,
232}
233
234#[derive(Debug, Clone)]
235enum RustUseKind {
236 Item { imported_name: String },
237 Module,
238}
239
240#[derive(Debug, Clone, Serialize)]
242pub struct CallTreeNode {
243 pub name: String,
245 pub file: String,
247 pub line: u32,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub signature: Option<String>,
252 pub resolved: bool,
254 pub children: Vec<CallTreeNode>,
256 pub depth_limited: bool,
258 pub truncated: usize,
260}
261
262const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
268
269pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
276 if exported && *kind == SymbolKind::Function {
278 return true;
279 }
280
281 let lower = name.to_lowercase();
283 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
284 return true;
285 }
286
287 match lang {
289 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
290 matches!(lower.as_str(), "describe" | "it" | "test")
292 || lower.starts_with("test")
293 || lower.starts_with("spec")
294 }
295 LangId::Python => {
296 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
298 }
299 LangId::Rust => {
300 lower.starts_with("test_")
302 }
303 LangId::Go => {
304 name.starts_with("Test")
306 }
307 LangId::C
308 | LangId::Cpp
309 | LangId::Zig
310 | LangId::CSharp
311 | LangId::Bash
312 | LangId::Solidity
313 | LangId::Scss
314 | LangId::Vue
315 | LangId::Json
316 | LangId::Scala
317 | LangId::Java
318 | LangId::Ruby
319 | LangId::Kotlin
320 | LangId::Swift
321 | LangId::Php
322 | LangId::Lua
323 | LangId::Perl
324 | LangId::Html
325 | LangId::Markdown
326 | LangId::Yaml
327 | LangId::Pascal
328 | LangId::R
329 | LangId::Groovy
330 | LangId::ObjC => false,
331 }
332}
333
334#[derive(Debug, Clone, Serialize)]
340pub struct TraceHop {
341 pub symbol: String,
343 pub file: String,
345 pub line: u32,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub signature: Option<String>,
350 pub is_entry_point: bool,
352}
353
354#[derive(Debug, Clone, Serialize)]
356pub struct TracePath {
357 pub hops: Vec<TraceHop>,
359}
360
361#[derive(Debug, Clone, Serialize)]
363pub struct TraceToResult {
364 pub target_symbol: String,
366 pub target_file: String,
368 pub paths: Vec<TracePath>,
370 pub total_paths: usize,
372 pub entry_points_found: usize,
374 pub max_depth_reached: bool,
376 pub truncated_paths: usize,
378}
379
380#[derive(Debug, Clone, Serialize)]
382pub struct TraceToSymbolHop {
383 pub symbol: String,
385 pub file: String,
387 pub line: u32,
389}
390
391#[derive(Debug, Clone, Serialize)]
393pub struct TraceToSymbolCandidate {
394 pub file: String,
396 pub line: u32,
398}
399
400#[derive(Debug, Clone, Serialize)]
402pub struct TraceToSymbolResult {
403 pub path: Option<Vec<TraceToSymbolHop>>,
405 pub complete: bool,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub reason: Option<String>,
410}
411
412#[derive(Debug, Clone, Serialize)]
418pub struct DataFlowHop {
419 pub file: String,
421 pub symbol: String,
423 pub variable: String,
425 pub line: u32,
427 pub flow_type: String,
429 pub approximate: bool,
431}
432
433#[derive(Debug, Clone, Serialize)]
436pub struct TraceDataResult {
437 pub expression: String,
439 pub origin_file: String,
441 pub origin_symbol: String,
443 pub hops: Vec<DataFlowHop>,
445 pub depth_limited: bool,
447}
448
449pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
455 let start = match signature.find('(') {
457 Some(i) => i + 1,
458 None => return Vec::new(),
459 };
460 let end = match signature[start..].find(')') {
461 Some(i) => start + i,
462 None => return Vec::new(),
463 };
464
465 let params_str = &signature[start..end].trim();
466 if params_str.is_empty() {
467 return Vec::new();
468 }
469
470 let parts = split_params(params_str);
472
473 let mut result = Vec::new();
474 for part in parts {
475 let trimmed = part.trim();
476 if trimmed.is_empty() {
477 continue;
478 }
479
480 match lang {
482 LangId::Rust => {
483 if trimmed == "self"
484 || trimmed == "mut self"
485 || trimmed.starts_with("&self")
486 || trimmed.starts_with("&mut self")
487 {
488 continue;
489 }
490 }
491 LangId::Python => {
492 if trimmed == "self" || trimmed.starts_with("self:") {
493 continue;
494 }
495 }
496 _ => {}
497 }
498
499 let name = extract_param_name(trimmed, lang);
501 if !name.is_empty() {
502 result.push(name);
503 }
504 }
505
506 result
507}
508
509fn split_params(s: &str) -> Vec<String> {
511 let mut parts = Vec::new();
512 let mut current = String::new();
513 let mut depth = 0i32;
514
515 for ch in s.chars() {
516 match ch {
517 '<' | '[' | '{' | '(' => {
518 depth += 1;
519 current.push(ch);
520 }
521 '>' | ']' | '}' | ')' => {
522 depth -= 1;
523 current.push(ch);
524 }
525 ',' if depth == 0 => {
526 parts.push(current.clone());
527 current.clear();
528 }
529 _ => {
530 current.push(ch);
531 }
532 }
533 }
534 if !current.is_empty() {
535 parts.push(current);
536 }
537 parts
538}
539
540fn extract_param_name(param: &str, lang: LangId) -> String {
548 let trimmed = param.trim();
549
550 let working = if trimmed.starts_with("...") {
552 &trimmed[3..]
553 } else if trimmed.starts_with("**") {
554 &trimmed[2..]
555 } else if trimmed.starts_with('*') && lang == LangId::Python {
556 &trimmed[1..]
557 } else {
558 trimmed
559 };
560
561 let working = if lang == LangId::Rust && working.starts_with("mut ") {
563 &working[4..]
564 } else {
565 working
566 };
567
568 let name = working
571 .split(|c: char| c == ':' || c == '=')
572 .next()
573 .unwrap_or("")
574 .trim();
575
576 let name = name.trim_end_matches('?');
578
579 if lang == LangId::Go && !name.contains(' ') {
581 return name.to_string();
582 }
583 if lang == LangId::Go {
584 return name.split_whitespace().next().unwrap_or("").to_string();
585 }
586
587 name.to_string()
588}
589
590pub struct CallGraph {
599 data: HashMap<PathBuf, FileCallData>,
601 project_root: PathBuf,
603}
604
605impl CallGraph {
606 pub fn new(project_root: PathBuf) -> Self {
608 clear_workspace_package_cache();
609 Self {
610 data: HashMap::new(),
611 project_root,
612 }
613 }
614
615 pub fn project_root(&self) -> &Path {
617 &self.project_root
618 }
619
620 fn resolve_cross_file_edge_with_exports<F, D>(
621 full_callee: &str,
622 short_name: &str,
623 caller_file: &Path,
624 import_block: &ImportBlock,
625 mut file_exports_symbol: F,
626 mut file_default_export_symbol: D,
627 ) -> EdgeResolution
628 where
629 F: FnMut(&Path, &str) -> bool,
630 D: FnMut(&Path) -> Option<String>,
631 {
632 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
633
634 if is_rust_source_file(caller_file) {
638 if let Some(target) = resolve_rust_cross_file_edge(
639 full_callee,
640 short_name,
641 caller_file,
642 import_block,
643 &mut file_exports_symbol,
644 ) {
645 return EdgeResolution::Resolved {
646 file: target.file,
647 symbol: target.symbol,
648 };
649 }
650 }
651
652 if full_callee.contains('.') {
654 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
655 if parts.len() == 2 {
656 let namespace = parts[0];
657 let member = parts[1];
658
659 for imp in &import_block.imports {
660 if imp.namespace_import.as_deref() == Some(namespace) {
661 if let Some(resolved_path) =
662 resolve_module_path(caller_dir, &imp.module_path)
663 {
664 if let Some(target) = resolve_reexported_symbol(
665 &resolved_path,
666 member,
667 &mut file_exports_symbol,
668 &mut file_default_export_symbol,
669 ) {
670 return EdgeResolution::Resolved {
671 file: target.file,
672 symbol: target.symbol,
673 };
674 }
675 }
676 }
677 }
678 }
679 }
680
681 for imp in &import_block.imports {
683 if imp.names.iter().any(|name| name == short_name) {
685 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
686 let target = resolve_reexported_symbol(
687 &resolved_path,
688 short_name,
689 &mut file_exports_symbol,
690 &mut file_default_export_symbol,
691 )
692 .unwrap_or(ResolvedSymbol {
693 file: resolved_path,
694 symbol: short_name.to_owned(),
695 });
696 return EdgeResolution::Resolved {
697 file: target.file,
698 symbol: target.symbol,
699 };
700 }
701 }
702
703 if imp.default_import.as_deref() == Some(short_name) {
705 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
706 let target = resolve_reexported_symbol(
707 &resolved_path,
708 "default",
709 &mut file_exports_symbol,
710 &mut file_default_export_symbol,
711 )
712 .unwrap_or_else(|| ResolvedSymbol {
713 symbol: file_default_export_symbol(&resolved_path)
714 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
715 file: resolved_path,
716 });
717 return EdgeResolution::Resolved {
718 file: target.file,
719 symbol: target.symbol,
720 };
721 }
722 }
723 }
724
725 if let Some((original_name, resolved_path)) =
730 resolve_aliased_import(short_name, import_block, caller_dir)
731 {
732 let target = resolve_reexported_symbol(
733 &resolved_path,
734 &original_name,
735 &mut file_exports_symbol,
736 &mut file_default_export_symbol,
737 )
738 .unwrap_or(ResolvedSymbol {
739 file: resolved_path,
740 symbol: original_name,
741 });
742 return EdgeResolution::Resolved {
743 file: target.file,
744 symbol: target.symbol,
745 };
746 }
747
748 for imp in &import_block.imports {
751 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
752 if resolved_path.is_dir() {
754 if let Some(index_path) = find_index_file(&resolved_path) {
755 if file_exports_symbol(&index_path, short_name) {
757 return EdgeResolution::Resolved {
758 file: index_path,
759 symbol: short_name.to_owned(),
760 };
761 }
762 }
763 } else if file_exports_symbol(&resolved_path, short_name) {
764 return EdgeResolution::Resolved {
765 file: resolved_path,
766 symbol: short_name.to_owned(),
767 };
768 }
769 }
770 }
771
772 EdgeResolution::Unresolved {
773 callee_name: short_name.to_owned(),
774 }
775 }
776
777 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
779 let canon = self.canonicalize(path)?;
780
781 if !self.data.contains_key(&canon) {
782 let file_data = build_file_data(&canon)?;
783 self.data.insert(canon.clone(), file_data);
784 }
785
786 Ok(&self.data[&canon])
787 }
788
789 pub fn resolve_cross_file_edge(
794 &mut self,
795 full_callee: &str,
796 short_name: &str,
797 caller_file: &Path,
798 import_block: &ImportBlock,
799 ) -> EdgeResolution {
800 let graph = RefCell::new(self);
801 Self::resolve_cross_file_edge_with_exports(
802 full_callee,
803 short_name,
804 caller_file,
805 import_block,
806 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
807 |path| graph.borrow_mut().file_default_export_symbol(path),
808 )
809 }
810
811 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
813 match self.build_file(path) {
814 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
815 Err(_) => false,
816 }
817 }
818
819 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
820 self.build_file(path)
821 .ok()
822 .and_then(|data| data.default_export_symbol.clone())
823 }
824
825 pub fn invalidate_file(&mut self, path: &Path) {
827 self.data.remove(path);
829 if let Ok(canon) = self.canonicalize(path) {
830 self.data.remove(&canon);
831 }
832 clear_workspace_package_cache();
833 }
834
835 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
837 let full_path = if path.is_relative() {
839 self.project_root.join(path)
840 } else {
841 path.to_path_buf()
842 };
843
844 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
846 }
847}
848
849pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
855 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
856 message: format!("unsupported file for call graph: {}", path.display()),
857 })?;
858
859 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
860 path: format!("{}: {}", path.display(), e),
861 })?;
862
863 build_file_data_from_source_with_lang(path, &source, lang)
864}
865
866pub(crate) fn build_file_data_from_source(
867 path: &Path,
868 source: &str,
869) -> Result<FileCallData, AftError> {
870 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
871 message: format!("unsupported file for call graph: {}", path.display()),
872 })?;
873 build_file_data_from_source_with_lang(path, source, lang)
874}
875
876#[derive(Debug)]
877struct SymbolCallRange {
878 symbol_index: usize,
879 byte_start: usize,
880 byte_end: usize,
881}
882
883struct SourceLineIndex {
884 bounds: Vec<(usize, usize)>,
885 source_len: usize,
886}
887
888impl SourceLineIndex {
889 fn new(source: &str) -> Self {
890 let bytes = source.as_bytes();
891 let mut bounds = Vec::new();
892 let mut line_start = 0usize;
893 let mut index = 0usize;
894
895 while index < bytes.len() {
896 match bytes[index] {
897 b'\r' => {
898 bounds.push((line_start, index));
899 index += if bytes.get(index + 1) == Some(&b'\n') {
900 2
901 } else {
902 1
903 };
904 line_start = index;
905 }
906 b'\n' => {
907 bounds.push((line_start, index));
908 index += 1;
909 line_start = index;
910 }
911 _ => index += 1,
912 }
913 }
914 bounds.push((line_start, bytes.len()));
915
916 Self {
917 bounds,
918 source_len: bytes.len(),
919 }
920 }
921
922 fn byte_offset(&self, line: u32, column: u32) -> usize {
923 let Some(&(line_start, line_end)) = self.bounds.get(line as usize) else {
924 return self.source_len;
925 };
926 line_start + (column as usize).min(line_end.saturating_sub(line_start))
927 }
928}
929
930fn collect_calls_by_symbol(
931 source: &str,
932 root: Node<'_>,
933 lang: LangId,
934 symbols: &[Symbol],
935) -> HashMap<String, Vec<CallSite>> {
936 attribute_sites_to_symbols(
937 source,
938 symbols,
939 extract_calls_full(source, root, 0, source.len(), lang),
940 )
941}
942
943fn collect_rust_value_refs_by_symbol(
944 source: &str,
945 root: Node<'_>,
946 symbols: &[Symbol],
947) -> HashMap<String, Vec<CallSite>> {
948 attribute_sites_to_symbols(source, symbols, extract_rust_value_references(source, root))
949}
950
951fn attribute_sites_to_symbols(
952 source: &str,
953 symbols: &[Symbol],
954 raw_sites: Vec<(String, String, u32, usize, usize)>,
955) -> HashMap<String, Vec<CallSite>> {
956 let line_index = SourceLineIndex::new(source);
957 let mut ranges = symbols
958 .iter()
959 .enumerate()
960 .map(|(symbol_index, symbol)| SymbolCallRange {
961 symbol_index,
962 byte_start: line_index.byte_offset(symbol.range.start_line, symbol.range.start_col),
963 byte_end: line_index.byte_offset(symbol.range.end_line, symbol.range.end_col),
964 })
965 .collect::<Vec<_>>();
966 ranges.sort_by(|left, right| {
967 left.byte_start
968 .cmp(&right.byte_start)
969 .then_with(|| left.symbol_index.cmp(&right.symbol_index))
970 });
971
972 let mut sites_by_symbol = vec![Vec::new(); symbols.len()];
973 let mut top_level_sites = Vec::new();
974 let mut active_ranges = Vec::<usize>::new();
975 let mut next_range = 0usize;
976
977 for (full, short, line, byte_start, byte_end) in raw_sites {
978 active_ranges.retain(|range_index| ranges[*range_index].byte_end > byte_start);
981 while next_range < ranges.len() && ranges[next_range].byte_start <= byte_start {
982 if ranges[next_range].byte_end > byte_start {
983 active_ranges.push(next_range);
984 }
985 next_range += 1;
986 }
987
988 let site = CallSite {
989 callee_name: short,
990 full_callee: full,
991 line,
992 byte_start,
993 byte_end,
994 };
995 let mut attributed = false;
996 for range_index in &active_ranges {
997 let range = &ranges[*range_index];
998 if byte_end <= range.byte_end {
999 sites_by_symbol[range.symbol_index].push(site.clone());
1000 attributed = true;
1001 }
1002 }
1003 if !attributed {
1004 top_level_sites.push(site);
1005 }
1006 }
1007
1008 let mut calls_by_symbol = HashMap::new();
1009 for (symbol, sites) in symbols.iter().zip(sites_by_symbol) {
1010 if !sites.is_empty() {
1011 calls_by_symbol.insert(symbol_identity(symbol), sites);
1012 }
1013 }
1014 if !top_level_sites.is_empty() {
1015 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
1016 }
1017 calls_by_symbol
1018}
1019
1020fn build_file_data_from_source_with_lang(
1021 path: &Path,
1022 source: &str,
1023 lang: LangId,
1024) -> Result<FileCallData, AftError> {
1025 let grammar = grammar_for(lang);
1026 let mut parser = Parser::new();
1027 parser
1028 .set_language(&grammar)
1029 .map_err(|e| AftError::ParseError {
1030 message: format!("grammar init failed for {:?}: {}", lang, e),
1031 })?;
1032
1033 let tree = parser
1034 .parse(&source, None)
1035 .ok_or_else(|| AftError::ParseError {
1036 message: format!("parse failed for {}", path.display()),
1037 })?;
1038
1039 let import_block = imports::parse_imports(&source, &tree, lang);
1041
1042 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
1044
1045 let root = tree.root_node();
1046 let mut calls_by_symbol = collect_calls_by_symbol(&source, root, lang, &symbols);
1047 let value_refs_by_symbol = if lang == LangId::Rust {
1048 collect_rust_value_refs_by_symbol(&source, root, &symbols)
1049 } else {
1050 HashMap::new()
1051 };
1052
1053 let default_export = find_default_export(&source, root, path, lang);
1054
1055 if let Some(default_export) = &default_export {
1056 if default_export.synthetic {
1057 let byte_start = default_export.node.byte_range().start;
1058 let byte_end = default_export.node.byte_range().end;
1059 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1060 let sites: Vec<CallSite> = raw_calls
1061 .into_iter()
1062 .filter(|(_, short, _, _, _)| *short != default_export.symbol)
1063 .map(
1064 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
1065 callee_name: short,
1066 full_callee: full,
1067 line,
1068 byte_start: call_byte_start,
1069 byte_end: call_byte_end,
1070 },
1071 )
1072 .collect();
1073 if !sites.is_empty() {
1074 calls_by_symbol.insert(default_export.symbol.clone(), sites);
1075 }
1076 }
1077 }
1078
1079 let mut exported_symbols: Vec<String> = symbols
1081 .iter()
1082 .filter(|s| s.exported)
1083 .map(|s| s.name.clone())
1084 .collect();
1085 if let Some(default_export) = &default_export {
1086 if !exported_symbols
1087 .iter()
1088 .any(|name| name == &default_export.symbol)
1089 {
1090 exported_symbols.push(default_export.symbol.clone());
1091 }
1092 }
1093
1094 let rust_attribute_entry_points = if lang == LangId::Rust {
1095 crate::parser::rust_attribute_entry_points(&source, root)
1096 .into_iter()
1097 .map(|entry| (entry.scoped_name, entry.attribute.to_string()))
1098 .collect::<HashMap<_, _>>()
1099 } else {
1100 HashMap::new()
1101 };
1102
1103 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
1105 .iter()
1106 .map(|s| {
1107 let identity = symbol_identity(s);
1108 (
1109 identity.clone(),
1110 SymbolMeta {
1111 kind: s.kind.clone(),
1112 exported: s.exported,
1113 signature: s.signature.clone(),
1114 line: s.range.start_line + 1,
1115 range: s.range.clone(),
1116 entry_point_attribute: rust_attribute_entry_points.get(&identity).cloned(),
1117 },
1118 )
1119 })
1120 .collect();
1121 if let Some(default_export) = &default_export {
1122 symbol_metadata
1123 .entry(default_export.symbol.clone())
1124 .or_insert_with(|| SymbolMeta {
1125 kind: default_export.kind.clone(),
1126 exported: true,
1127 signature: Some(first_line_signature(&source, &default_export.node)),
1128 line: default_export.node.start_position().row as u32 + 1,
1129 range: crate::parser::node_range(&default_export.node),
1130 entry_point_attribute: None,
1131 });
1132 }
1133 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL)
1134 || value_refs_by_symbol.contains_key(TOP_LEVEL_SYMBOL)
1135 {
1136 symbol_metadata
1137 .entry(TOP_LEVEL_SYMBOL.to_string())
1138 .or_insert(SymbolMeta {
1139 kind: SymbolKind::Function,
1140 exported: false,
1141 signature: None,
1142 line: 1,
1143 range: Range {
1144 start_line: 0,
1145 start_col: 0,
1146 end_line: 0,
1147 end_col: 0,
1148 },
1149 entry_point_attribute: None,
1150 });
1151 }
1152
1153 Ok(FileCallData {
1154 calls_by_symbol,
1155 value_refs_by_symbol,
1156 exported_symbols,
1157 symbol_metadata,
1158 default_export_symbol: default_export.map(|export| export.symbol),
1159 import_block,
1160 lang,
1161 })
1162}
1163
1164#[derive(Debug, Clone)]
1165struct DefaultExport<'tree> {
1166 symbol: String,
1167 synthetic: bool,
1168 kind: SymbolKind,
1169 node: Node<'tree>,
1170}
1171
1172fn find_default_export<'tree>(
1173 source: &str,
1174 root: Node<'tree>,
1175 path: &Path,
1176 lang: LangId,
1177) -> Option<DefaultExport<'tree>> {
1178 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
1179 return None;
1180 }
1181 find_default_export_inner(source, root, path)
1182}
1183
1184fn find_default_export_inner<'tree>(
1185 source: &str,
1186 node: Node<'tree>,
1187 path: &Path,
1188) -> Option<DefaultExport<'tree>> {
1189 if node.kind() == "export_statement" {
1190 if let Some(default_export) = default_export_from_statement(source, node, path) {
1191 return Some(default_export);
1192 }
1193 }
1194
1195 let mut cursor = node.walk();
1196 if !cursor.goto_first_child() {
1197 return None;
1198 }
1199
1200 loop {
1201 let child = cursor.node();
1202 if let Some(default_export) = find_default_export_inner(source, child, path) {
1203 return Some(default_export);
1204 }
1205 if !cursor.goto_next_sibling() {
1206 break;
1207 }
1208 }
1209
1210 None
1211}
1212
1213fn default_export_from_statement<'tree>(
1214 source: &str,
1215 node: Node<'tree>,
1216 path: &Path,
1217) -> Option<DefaultExport<'tree>> {
1218 let mut cursor = node.walk();
1219 if !cursor.goto_first_child() {
1220 return None;
1221 }
1222
1223 let mut saw_default = false;
1224 loop {
1225 let child = cursor.node();
1226 match child.kind() {
1227 "default" => saw_default = true,
1228 "function_declaration" | "generator_function_declaration" | "class_declaration"
1229 if saw_default =>
1230 {
1231 if let Some(name_node) = child.child_by_field_name("name") {
1232 return Some(DefaultExport {
1233 symbol: source[name_node.byte_range()].to_string(),
1234 synthetic: false,
1235 kind: default_export_kind(&child),
1236 node: child,
1237 });
1238 }
1239 return Some(DefaultExport {
1240 symbol: synthetic_default_symbol(path),
1241 synthetic: true,
1242 kind: default_export_kind(&child),
1243 node: child,
1244 });
1245 }
1246 "arrow_function"
1247 | "function"
1248 | "function_expression"
1249 | "class"
1250 | "class_expression"
1251 if saw_default =>
1252 {
1253 return Some(DefaultExport {
1254 symbol: synthetic_default_symbol(path),
1255 synthetic: true,
1256 kind: default_export_kind(&child),
1257 node: child,
1258 });
1259 }
1260 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
1261 return Some(DefaultExport {
1262 symbol: source[child.byte_range()].to_string(),
1263 synthetic: false,
1264 kind: SymbolKind::Function,
1265 node: child,
1266 });
1267 }
1268 _ => {}
1269 }
1270 if !cursor.goto_next_sibling() {
1271 break;
1272 }
1273 }
1274
1275 None
1276}
1277
1278fn default_export_kind(node: &Node) -> SymbolKind {
1279 if node.kind().contains("class") {
1280 SymbolKind::Class
1281 } else {
1282 SymbolKind::Function
1283 }
1284}
1285
1286fn synthetic_default_symbol(path: &Path) -> String {
1287 let file_name = path
1288 .file_name()
1289 .and_then(|name| name.to_str())
1290 .unwrap_or("unknown");
1291 format!("<default:{file_name}>")
1292}
1293
1294fn first_line_signature(source: &str, node: &Node) -> String {
1295 let text = &source[node.byte_range()];
1296 let first_line = text.lines().next().unwrap_or(text);
1297 first_line
1298 .trim_end()
1299 .trim_end_matches('{')
1300 .trim_end()
1301 .to_string()
1302}
1303
1304fn node_text(node: tree_sitter::Node, source: &str) -> String {
1305 source[node.start_byte()..node.end_byte()].to_string()
1306}
1307
1308fn find_child_by_kind<'a>(
1310 node: tree_sitter::Node<'a>,
1311 kind: &str,
1312) -> Option<tree_sitter::Node<'a>> {
1313 let mut cursor = node.walk();
1314 if cursor.goto_first_child() {
1315 loop {
1316 if cursor.node().kind() == kind {
1317 return Some(cursor.node());
1318 }
1319 if !cursor.goto_next_sibling() {
1320 break;
1321 }
1322 }
1323 }
1324 None
1325}
1326
1327#[cfg(test)]
1328#[derive(Debug, Clone)]
1329struct CallSiteWithRange {
1330 full: String,
1331 short: String,
1332 line: u32,
1333 byte_start: usize,
1334 byte_end: usize,
1335}
1336
1337#[cfg(test)]
1338fn collect_calls_full_with_ranges(
1339 root: tree_sitter::Node,
1340 source: &str,
1341 byte_start: usize,
1342 byte_end: usize,
1343 lang: LangId,
1344) -> Vec<CallSiteWithRange> {
1345 let mut results = Vec::new();
1346 let call_kinds = call_node_kinds(lang);
1347 collect_calls_full_with_ranges_inner(
1348 root,
1349 source,
1350 byte_start,
1351 byte_end,
1352 &call_kinds,
1353 &mut results,
1354 );
1355 results
1356}
1357
1358#[cfg(test)]
1359fn collect_calls_full_with_ranges_inner(
1360 node: tree_sitter::Node,
1361 source: &str,
1362 byte_start: usize,
1363 byte_end: usize,
1364 call_kinds: &[&str],
1365 results: &mut Vec<CallSiteWithRange>,
1366) {
1367 let node_start = node.start_byte();
1368 let node_end = node.end_byte();
1369
1370 if node_end <= byte_start || node_start >= byte_end {
1371 return;
1372 }
1373
1374 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
1375 if let (Some(full), Some(short)) = (
1376 extract_full_callee(&node, source),
1377 extract_callee_name(&node, source),
1378 ) {
1379 results.push(CallSiteWithRange {
1380 full,
1381 short,
1382 line: node.start_position().row as u32 + 1,
1383 byte_start: node_start,
1384 byte_end: node_end,
1385 });
1386 }
1387 }
1388
1389 let mut cursor = node.walk();
1390 if cursor.goto_first_child() {
1391 loop {
1392 collect_calls_full_with_ranges_inner(
1393 cursor.node(),
1394 source,
1395 byte_start,
1396 byte_end,
1397 call_kinds,
1398 results,
1399 );
1400 if !cursor.goto_next_sibling() {
1401 break;
1402 }
1403 }
1404 }
1405}
1406
1407pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1415 if module_path.starts_with('.') {
1416 return resolve_relative_module_path(from_dir, module_path);
1417 }
1418
1419 if module_path.starts_with('/') {
1420 return None;
1421 }
1422
1423 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
1424 return Some(path);
1425 }
1426
1427 resolve_workspace_module_path(from_dir, module_path)
1428}
1429
1430fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1431 let base = from_dir.join(module_path);
1432 resolve_file_like_path(&base)
1433}
1434
1435fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
1436 let base = base.to_path_buf();
1437
1438 if base.is_file() {
1440 return Some(std::fs::canonicalize(&base).unwrap_or(base));
1441 }
1442
1443 for ext in JS_TS_EXTENSIONS {
1445 let with_ext = base.with_extension(ext);
1446 if with_ext.is_file() {
1447 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
1448 }
1449 }
1450
1451 if base.is_dir() {
1453 if let Some(index) = find_index_file(&base) {
1454 return Some(index);
1455 }
1456 }
1457
1458 None
1459}
1460
1461fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1462 let (package_name, subpath) = split_package_import(module_path)?;
1463 let package_root = find_package_root_for_import(from_dir, &package_name)?;
1464 resolve_package_entry(&package_root, &subpath)
1465}
1466
1467fn is_rust_source_file(path: &Path) -> bool {
1468 path.extension().and_then(|ext| ext.to_str()) == Some("rs")
1469}
1470
1471fn resolve_rust_cross_file_edge<F>(
1472 full_callee: &str,
1473 short_name: &str,
1474 caller_file: &Path,
1475 import_block: &ImportBlock,
1476 file_exports_symbol: &mut F,
1477) -> Option<ResolvedSymbol>
1478where
1479 F: FnMut(&Path, &str) -> bool,
1480{
1481 if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
1482 {
1483 return Some(target);
1484 }
1485
1486 resolve_rust_imported_call(
1487 caller_file,
1488 full_callee,
1489 short_name,
1490 import_block,
1491 file_exports_symbol,
1492 )
1493}
1494
1495fn resolve_rust_qualified_call<F>(
1496 caller_file: &Path,
1497 full_callee: &str,
1498 file_exports_symbol: &mut F,
1499) -> Option<ResolvedSymbol>
1500where
1501 F: FnMut(&Path, &str) -> bool,
1502{
1503 if !full_callee.contains("::") {
1504 return None;
1505 }
1506
1507 let segments = rust_path_segments(full_callee)?;
1508 resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
1509}
1510
1511fn resolve_rust_imported_call<F>(
1512 caller_file: &Path,
1513 full_callee: &str,
1514 short_name: &str,
1515 import_block: &ImportBlock,
1516 file_exports_symbol: &mut F,
1517) -> Option<ResolvedSymbol>
1518where
1519 F: FnMut(&Path, &str) -> bool,
1520{
1521 let call_segments = rust_path_segments(full_callee).unwrap_or_default();
1522 let bare_call_name = if call_segments.len() <= 1 {
1523 call_segments
1524 .first()
1525 .map(String::as_str)
1526 .unwrap_or(short_name)
1527 } else {
1528 short_name
1529 };
1530
1531 for imp in &import_block.imports {
1532 for entry in rust_use_entries(imp) {
1533 match &entry.kind {
1534 RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
1535 if entry.local_name != bare_call_name {
1536 continue;
1537 }
1538 let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
1539 else {
1540 continue;
1541 };
1542 if file_exports_symbol(&file, imported_name) {
1543 return Some(ResolvedSymbol {
1544 file,
1545 symbol: imported_name.clone(),
1546 });
1547 }
1548 }
1549 RustUseKind::Module if call_segments.len() >= 2 => {
1550 if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
1551 {
1552 continue;
1553 }
1554 let symbol = call_segments.last()?.clone();
1555 let mut module_path = entry.module_path.clone();
1556 for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
1557 module_path.push_str("::");
1558 module_path.push_str(segment);
1559 }
1560 let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
1561 continue;
1562 };
1563 if file_exports_symbol(&file, &symbol) {
1564 return Some(ResolvedSymbol { file, symbol });
1565 }
1566 }
1567 _ => {}
1568 }
1569 }
1570 }
1571
1572 None
1573}
1574
1575fn resolve_rust_call_segments<F>(
1576 caller_file: &Path,
1577 segments: &[String],
1578 file_exports_symbol: &mut F,
1579) -> Option<ResolvedSymbol>
1580where
1581 F: FnMut(&Path, &str) -> bool,
1582{
1583 if segments.len() < 2 {
1584 return None;
1585 }
1586
1587 let symbol = segments.last()?.clone();
1588 let module_path = segments[..segments.len() - 1].join("::");
1589 let file = resolve_rust_module_path(caller_file, &module_path)?;
1590 if file_exports_symbol(&file, &symbol) {
1591 Some(ResolvedSymbol { file, symbol })
1592 } else {
1593 None
1594 }
1595}
1596
1597fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
1598 let segments = rust_path_segments(module_path)?;
1599 let first = segments.first()?.as_str();
1600
1601 match first {
1602 "std" | "core" | "alloc" => None,
1603 "crate" => {
1604 let crate_root = find_rust_crate_root(caller_file)?;
1605 let crate_info = rust_crate_info(&crate_root)?;
1606 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1607 resolve_rust_module_segments(&base, &segments[1..])
1608 }
1609 "self" => {
1610 let crate_root = find_rust_crate_root(caller_file)?;
1611 let crate_info = rust_crate_info(&crate_root)?;
1612 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1613 if segments.len() == 1 {
1614 return Some(canonicalize_path(caller_file));
1615 }
1616 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1617 target_segments.extend(segments[1..].iter().cloned());
1618 resolve_rust_module_segments(&base, &target_segments)
1619 }
1620 "super" => {
1621 let crate_root = find_rust_crate_root(caller_file)?;
1622 let crate_info = rust_crate_info(&crate_root)?;
1623 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1624 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1625 target_segments.pop();
1626 target_segments.extend(segments[1..].iter().cloned());
1627 resolve_rust_module_segments(&base, &target_segments)
1628 }
1629 crate_name => {
1630 let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
1631 let workspace_crates = rust_workspace_crates(caller_dir)?;
1632 let crate_info = workspace_crates.get(crate_name)?;
1633 let base = rust_lib_module_base(crate_info)?;
1634 resolve_rust_module_segments(&base, &segments[1..])
1635 }
1636 }
1637}
1638
1639fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
1640 let Some(body) = rust_use_body(&imp.raw_text) else {
1641 return Vec::new();
1642 };
1643 let mut entries = Vec::new();
1644 expand_rust_use_tree(body, &mut entries);
1645 entries
1646}
1647
1648fn rust_use_body(raw: &str) -> Option<&str> {
1649 let use_pos = raw.find("use ")?;
1650 let body = raw[use_pos + 4..].trim();
1651 let body = body.strip_suffix(';').unwrap_or(body).trim();
1652 (!body.is_empty()).then_some(body)
1653}
1654
1655fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
1656 let path = path.trim();
1657 if path.is_empty() {
1658 return;
1659 }
1660
1661 if let Some((prefix, inner)) = split_rust_use_braces(path) {
1662 let prefix = prefix.trim().trim_end_matches("::").trim();
1663 for part in split_top_level_commas(inner) {
1664 let part = part.trim();
1665 if part.is_empty() {
1666 continue;
1667 }
1668 if part == "self" {
1669 if let Some(local_name) = rust_last_path_segment(prefix) {
1670 entries.push(RustUseEntry {
1671 module_path: prefix.to_string(),
1672 local_name,
1673 kind: RustUseKind::Module,
1674 });
1675 }
1676 continue;
1677 }
1678 let combined = if prefix.is_empty() {
1679 part.to_string()
1680 } else {
1681 format!("{prefix}::{part}")
1682 };
1683 expand_rust_use_tree(&combined, entries);
1684 }
1685 return;
1686 }
1687
1688 add_rust_use_leaf(path, entries);
1689}
1690
1691fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
1692 let mut depth = 0usize;
1693 let mut start = None;
1694 for (idx, ch) in path.char_indices() {
1695 match ch {
1696 '{' => {
1697 if depth == 0 {
1698 start = Some(idx);
1699 }
1700 depth += 1;
1701 }
1702 '}' => {
1703 depth = depth.checked_sub(1)?;
1704 if depth == 0 {
1705 let start = start?;
1706 if !path[idx + ch.len_utf8()..].trim().is_empty() {
1707 return None;
1708 }
1709 return Some((&path[..start], &path[start + 1..idx]));
1710 }
1711 }
1712 _ => {}
1713 }
1714 }
1715 None
1716}
1717
1718fn split_top_level_commas(value: &str) -> Vec<&str> {
1719 let mut parts = Vec::new();
1720 let mut depth = 0usize;
1721 let mut start = 0usize;
1722 for (idx, ch) in value.char_indices() {
1723 match ch {
1724 '{' => depth += 1,
1725 '}' => depth = depth.saturating_sub(1),
1726 ',' if depth == 0 => {
1727 parts.push(&value[start..idx]);
1728 start = idx + ch.len_utf8();
1729 }
1730 _ => {}
1731 }
1732 }
1733 parts.push(&value[start..]);
1734 parts
1735}
1736
1737fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
1738 let (path, alias) = split_rust_alias(path);
1739 let Some(segments) = rust_path_segments(path) else {
1740 return;
1741 };
1742 if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
1743 return;
1744 }
1745
1746 let imported_name = segments.last().cloned().unwrap_or_default();
1747 let local_name = alias.unwrap_or(&imported_name).to_string();
1748 if segments.len() >= 2 {
1749 entries.push(RustUseEntry {
1750 module_path: segments[..segments.len() - 1].join("::"),
1751 local_name: local_name.clone(),
1752 kind: RustUseKind::Item {
1753 imported_name: imported_name.clone(),
1754 },
1755 });
1756 }
1757
1758 entries.push(RustUseEntry {
1759 module_path: segments.join("::"),
1760 local_name,
1761 kind: RustUseKind::Module,
1762 });
1763}
1764
1765fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
1766 if let Some(idx) = path.rfind(" as ") {
1767 let original = path[..idx].trim();
1768 let alias = path[idx + 4..].trim();
1769 if !original.is_empty() && !alias.is_empty() {
1770 return (original, Some(alias));
1771 }
1772 }
1773 (path.trim(), None)
1774}
1775
1776fn rust_path_segments(path: &str) -> Option<Vec<String>> {
1777 let path = path.trim().trim_end_matches(';').trim();
1778 if path.is_empty() || path.contains('{') || path.contains('}') {
1779 return None;
1780 }
1781
1782 let mut segments = Vec::new();
1783 for raw_segment in path.split("::") {
1784 let segment = raw_segment.trim();
1785 if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
1786 return None;
1787 }
1788 let segment = segment.strip_prefix("r#").unwrap_or(segment);
1789 if segment
1790 .chars()
1791 .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
1792 {
1793 return None;
1794 }
1795 segments.push(segment.to_string());
1796 }
1797
1798 (!segments.is_empty()).then_some(segments)
1799}
1800
1801fn rust_last_path_segment(path: &str) -> Option<String> {
1802 rust_path_segments(path)?.last().cloned()
1803}
1804
1805fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
1806 let mut current = if from.is_file() {
1807 from.parent()
1808 } else {
1809 Some(from)
1810 };
1811 while let Some(dir) = current {
1812 if dir.join("Cargo.toml").is_file() {
1813 return Some(canonicalize_path(dir));
1814 }
1815 current = dir.parent();
1816 }
1817 None
1818}
1819
1820fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1821 let root = canonicalize_path(crate_root);
1822 if let Some(cached) = RUST_CRATE_INFO_CACHE
1823 .read()
1824 .ok()
1825 .and_then(|cache| cache.get(&root).cloned())
1826 {
1827 return cached;
1828 }
1829
1830 let resolved = read_rust_crate_info(&root);
1831 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
1832 cache.insert(root, resolved.clone());
1833 }
1834 resolved
1835}
1836
1837fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1838 let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
1839 let package = cargo.get("package")?;
1840 let package_name = package.get("name")?.as_str()?;
1841 let lib_name = cargo
1842 .get("lib")
1843 .and_then(|lib| lib.get("name"))
1844 .and_then(|name| name.as_str())
1845 .map(ToOwned::to_owned)
1846 .unwrap_or_else(|| package_name.replace('-', "_"));
1847
1848 let lib_root = cargo
1849 .get("lib")
1850 .and_then(|lib| lib.get("path"))
1851 .and_then(|path| path.as_str())
1852 .map(|path| crate_root.join(path))
1853 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
1854 let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
1855
1856 let main_root = crate_root.join("src/main.rs");
1857 let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
1858
1859 Some(RustCrateInfo {
1860 lib_name,
1861 lib_root,
1862 main_root,
1863 })
1864}
1865
1866fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
1867 let source = std::fs::read_to_string(path).ok()?;
1868 toml::from_str(&source).ok()
1869}
1870
1871fn rust_module_base_for_caller(
1872 crate_info: &RustCrateInfo,
1873 caller_file: &Path,
1874) -> Option<RustModuleBase> {
1875 let caller = canonicalize_path(caller_file);
1876 if crate_info.main_root.as_ref() == Some(&caller) {
1877 return rust_main_module_base(crate_info);
1878 }
1879 rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
1880}
1881
1882fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1883 let root_file = crate_info.lib_root.clone()?;
1884 let src_dir = root_file.parent()?.to_path_buf();
1885 Some(RustModuleBase { src_dir, root_file })
1886}
1887
1888fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1889 let root_file = crate_info.main_root.clone()?;
1890 let src_dir = root_file.parent()?.to_path_buf();
1891 Some(RustModuleBase { src_dir, root_file })
1892}
1893
1894fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
1895 if segments.is_empty() {
1896 return Some(base.root_file.clone());
1897 }
1898
1899 let module_base = segments
1900 .iter()
1901 .fold(base.src_dir.clone(), |path, segment| path.join(segment));
1902 let file_path = module_base.with_extension("rs");
1903 if file_path.is_file() {
1904 return Some(canonicalize_path(&file_path));
1905 }
1906
1907 let mod_path = module_base.join("mod.rs");
1908 if mod_path.is_file() {
1909 return Some(canonicalize_path(&mod_path));
1910 }
1911
1912 None
1913}
1914
1915fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
1916 let src_dir = canonicalize_path(src_dir);
1917 let file = canonicalize_path(file);
1918 let rel = file.strip_prefix(&src_dir).ok()?;
1919 let mut parts: Vec<String> = rel
1920 .components()
1921 .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
1922 .collect();
1923 if parts.is_empty() {
1924 return None;
1925 }
1926
1927 let last = parts.pop()?;
1928 if last == "lib.rs" || last == "main.rs" {
1929 return Some(Vec::new());
1930 }
1931 if last == "mod.rs" {
1932 return Some(parts);
1933 }
1934 let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
1935 parts.push(stem);
1936 Some(parts)
1937}
1938
1939fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
1940 let workspace_root =
1941 find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
1942 let workspace_root = canonicalize_path(&workspace_root);
1943
1944 if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
1945 .read()
1946 .ok()
1947 .and_then(|cache| cache.get(&workspace_root).cloned())
1948 {
1949 return Some(cached);
1950 }
1951
1952 let mut crates = HashMap::new();
1953 for member in rust_workspace_member_dirs(&workspace_root) {
1954 if let Some(info) = rust_crate_info(&member) {
1955 if info.lib_root.is_some() {
1956 crates.insert(info.lib_name.clone(), info);
1957 }
1958 }
1959 }
1960 if let Some(info) = rust_crate_info(&workspace_root) {
1961 if info.lib_root.is_some() {
1962 crates.insert(info.lib_name.clone(), info);
1963 }
1964 }
1965
1966 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
1967 cache.insert(workspace_root, crates.clone());
1968 }
1969 Some(crates)
1970}
1971
1972fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
1973 let mut current = Some(from_dir);
1974 while let Some(dir) = current {
1975 let cargo = dir.join("Cargo.toml");
1976 if rust_manifest_value(&cargo)
1977 .and_then(|value| value.get("workspace").cloned())
1978 .is_some()
1979 {
1980 return Some(canonicalize_path(dir));
1981 }
1982 current = dir.parent();
1983 }
1984 None
1985}
1986
1987fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
1988 let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
1989 return Vec::new();
1990 };
1991 let Some(members) = cargo
1992 .get("workspace")
1993 .and_then(|workspace| workspace.get("members"))
1994 .and_then(|members| members.as_array())
1995 else {
1996 return Vec::new();
1997 };
1998
1999 let mut dirs = Vec::new();
2000 for member in members.iter().filter_map(|member| member.as_str()) {
2001 dirs.extend(expand_rust_workspace_member(workspace_root, member));
2002 }
2003 dirs.sort();
2004 dirs.dedup();
2005 dirs
2006}
2007
2008fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
2009 let member = member.trim();
2010 if member.is_empty() {
2011 return Vec::new();
2012 }
2013
2014 if member.contains('*') || member.contains('?') || member.contains('[') {
2015 let pattern = workspace_root.join(member).to_string_lossy().to_string();
2016 return glob::glob(&pattern)
2017 .ok()
2018 .into_iter()
2019 .flatten()
2020 .filter_map(Result::ok)
2021 .filter(|path| path.join("Cargo.toml").is_file())
2022 .map(|path| canonicalize_path(&path))
2023 .collect();
2024 }
2025
2026 let path = workspace_root.join(member);
2027 if path.join("Cargo.toml").is_file() {
2028 vec![canonicalize_path(&path)]
2029 } else {
2030 Vec::new()
2031 }
2032}
2033
2034fn canonicalize_path(path: &Path) -> PathBuf {
2035 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2036}
2037
2038fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2039 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2040 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2041 let compiler_options = tsconfig.get("compilerOptions")?;
2042 let paths = compiler_options.get("paths")?.as_object()?;
2043 let base_url = compiler_options
2044 .get("baseUrl")
2045 .and_then(Value::as_str)
2046 .unwrap_or(".");
2047 let base_dir = tsconfig_dir.join(base_url);
2048
2049 for (alias, targets) in paths {
2050 let Some(capture) = ts_path_capture(alias, module_path) else {
2051 continue;
2052 };
2053 let Some(targets) = targets.as_array() else {
2054 continue;
2055 };
2056 for target in targets.iter().filter_map(Value::as_str) {
2057 let target = if target.contains('*') {
2058 target.replace('*', capture)
2059 } else {
2060 target.to_string()
2061 };
2062 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2063 return Some(path);
2064 }
2065 }
2066 }
2067
2068 None
2069}
2070
2071fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2072 let mut current = Some(from_dir);
2073 while let Some(dir) = current {
2074 if dir.join("tsconfig.json").is_file() {
2075 return Some(dir.to_path_buf());
2076 }
2077 current = dir.parent();
2078 }
2079 None
2080}
2081
2082fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2083 if let Some(star_index) = alias.find('*') {
2084 let (prefix, suffix_with_star) = alias.split_at(star_index);
2085 let suffix = &suffix_with_star[1..];
2086 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2087 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2088 }
2089 return None;
2090 }
2091
2092 (alias == module_path).then_some("")
2093}
2094
2095fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2096 let mut parts = module_path.split('/');
2097 let first = parts.next()?;
2098 if first.is_empty() {
2099 return None;
2100 }
2101
2102 if first.starts_with('@') {
2103 let second = parts.next()?;
2104 if second.is_empty() {
2105 return None;
2106 }
2107 let package_name = format!("{first}/{second}");
2108 let subpath = parts.collect::<Vec<_>>().join("/");
2109 let subpath = (!subpath.is_empty()).then_some(subpath);
2110 Some((package_name, subpath))
2111 } else {
2112 let package_name = first.to_string();
2113 let subpath = parts.collect::<Vec<_>>().join("/");
2114 let subpath = (!subpath.is_empty()).then_some(subpath);
2115 Some((package_name, subpath))
2116 }
2117}
2118
2119fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2120 let mut current = Some(from_dir);
2121 while let Some(dir) = current {
2122 if package_json_name(dir).as_deref() == Some(package_name) {
2123 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2124 }
2125 current = dir.parent();
2126 }
2127
2128 find_workspace_root(from_dir)
2129 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2130}
2131
2132fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2133 let mut current = Some(from_dir);
2134 while let Some(dir) = current {
2135 if is_workspace_root(dir) {
2136 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2137 }
2138 current = dir.parent();
2139 }
2140 None
2141}
2142
2143fn is_workspace_root(dir: &Path) -> bool {
2144 package_json_value(dir)
2145 .map(|value| !workspace_patterns(&value).is_empty())
2146 .unwrap_or(false)
2147 || !pnpm_workspace_patterns(dir).is_empty()
2148}
2149
2150pub(crate) fn clear_workspace_package_cache() {
2151 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2152 cache.clear();
2153 }
2154 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2155 cache.clear();
2156 }
2157 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2158 cache.clear();
2159 }
2160}
2161
2162fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2163 let workspace_root =
2164 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2165 let cache_key = (workspace_root.clone(), package_name.to_string());
2166
2167 if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2168 if let Some(cached) = cache.get(&cache_key) {
2169 return cached.clone();
2170 }
2171 }
2172
2173 let resolved = workspace_member_dirs(&workspace_root)
2174 .into_iter()
2175 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2176 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2177
2178 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2179 cache.insert(cache_key, resolved.clone());
2180 }
2181
2182 resolved
2183}
2184
2185fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2186 let mut patterns = package_json_value(workspace_root)
2187 .map(|package_json| workspace_patterns(&package_json))
2188 .unwrap_or_default();
2189 patterns.extend(pnpm_workspace_patterns(workspace_root));
2190
2191 expand_workspace_patterns(workspace_root, &patterns)
2192}
2193
2194fn workspace_patterns(package_json: &Value) -> Vec<String> {
2195 match package_json.get("workspaces") {
2196 Some(Value::Array(items)) => items
2197 .iter()
2198 .filter_map(non_empty_workspace_pattern)
2199 .collect(),
2200 Some(Value::Object(map)) => map
2201 .get("packages")
2202 .and_then(Value::as_array)
2203 .map(|items| {
2204 items
2205 .iter()
2206 .filter_map(non_empty_workspace_pattern)
2207 .collect()
2208 })
2209 .unwrap_or_default(),
2210 _ => Vec::new(),
2211 }
2212}
2213
2214fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2215 let pattern = value.as_str()?.trim();
2216 (!pattern.is_empty()).then(|| pattern.to_string())
2217}
2218
2219fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2220 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2221 return Vec::new();
2222 };
2223
2224 let mut patterns = Vec::new();
2225 let mut in_packages = false;
2226 for line in source.lines() {
2227 let without_comment = line.split('#').next().unwrap_or("").trim_end();
2228 let trimmed = without_comment.trim();
2229 if trimmed.is_empty() {
2230 continue;
2231 }
2232 if trimmed == "packages:" {
2233 in_packages = true;
2234 continue;
2235 }
2236 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2237 in_packages = false;
2238 }
2239 if in_packages {
2240 if let Some(pattern) = trimmed.strip_prefix('-') {
2241 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2242 if !pattern.is_empty() {
2243 patterns.push(pattern.to_string());
2244 }
2245 }
2246 }
2247 }
2248 patterns
2249}
2250
2251fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2252 let positive_patterns: Vec<&str> = patterns
2253 .iter()
2254 .map(|pattern| pattern.trim())
2255 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2256 .collect();
2257 if positive_patterns.is_empty() {
2258 return Vec::new();
2259 }
2260
2261 let positives = build_glob_set(&positive_patterns);
2262 let negative_patterns: Vec<&str> = patterns
2263 .iter()
2264 .map(|pattern| pattern.trim())
2265 .filter_map(|pattern| pattern.strip_prefix('!'))
2266 .map(str::trim)
2267 .filter(|pattern| !pattern.is_empty())
2268 .collect();
2269 let negatives = build_glob_set(&negative_patterns);
2270
2271 let mut members = Vec::new();
2272 collect_workspace_member_dirs(
2273 workspace_root,
2274 workspace_root,
2275 &positives,
2276 &negatives,
2277 &mut members,
2278 );
2279 members
2280}
2281
2282fn build_glob_set(patterns: &[&str]) -> GlobSet {
2283 let mut builder = GlobSetBuilder::new();
2284 for pattern in patterns {
2285 if let Ok(glob) = Glob::new(pattern) {
2286 builder.add(glob);
2287 }
2288 }
2289 builder
2290 .build()
2291 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
2292}
2293
2294fn collect_workspace_member_dirs(
2295 workspace_root: &Path,
2296 dir: &Path,
2297 positives: &GlobSet,
2298 negatives: &GlobSet,
2299 members: &mut Vec<PathBuf>,
2300) {
2301 let Ok(entries) = std::fs::read_dir(dir) else {
2302 return;
2303 };
2304
2305 for entry in entries.filter_map(Result::ok) {
2306 let path = entry.path();
2307 let Ok(file_type) = entry.file_type() else {
2308 continue;
2309 };
2310 if !file_type.is_dir() {
2311 continue;
2312 }
2313 let name = entry.file_name();
2314 let name = name.to_string_lossy();
2315 if matches!(
2316 name.as_ref(),
2317 "node_modules" | ".git" | "target" | "dist" | "build"
2318 ) {
2319 continue;
2320 }
2321
2322 if path.join("package.json").is_file() {
2323 if let Ok(rel) = path.strip_prefix(workspace_root) {
2324 let rel = rel.to_string_lossy().replace('\\', "/");
2325 if positives.is_match(&rel) && !negatives.is_match(&rel) {
2326 members.push(path.clone());
2327 }
2328 }
2329 }
2330
2331 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
2332 }
2333}
2334
2335fn package_json_value(dir: &Path) -> Option<Value> {
2336 package_json_like_value(&dir.join("package.json"))
2337}
2338
2339fn package_json_like_value(path: &Path) -> Option<Value> {
2340 let json = std::fs::read_to_string(path).ok()?;
2341 serde_json::from_str(&json).ok()
2342}
2343
2344fn package_json_name(dir: &Path) -> Option<String> {
2345 package_json_value(dir)?
2346 .get("name")?
2347 .as_str()
2348 .map(ToOwned::to_owned)
2349}
2350
2351fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
2352 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
2353
2354 if let Some(exports) = package_json.get("exports") {
2355 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2356 if let Some(path) = resolve_package_target(package_root, &target) {
2357 return Some(path);
2358 }
2359 }
2360 }
2361
2362 if subpath.is_none() {
2363 for field in ["module", "main"] {
2364 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2365 if let Some(path) = resolve_package_target(package_root, target) {
2366 return Some(path);
2367 }
2368 }
2369 }
2370 }
2371
2372 resolve_package_fallback(package_root, subpath.as_deref())
2373}
2374
2375fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2376 let key = subpath
2377 .map(|value| format!("./{value}"))
2378 .unwrap_or_else(|| ".".to_string());
2379
2380 match exports {
2381 Value::String(target) if key == "." => Some(target.clone()),
2382 Value::Object(map) => {
2383 if let Some(target) = map.get(&key).and_then(export_condition_target) {
2384 return Some(target);
2385 }
2386
2387 if let Some(target) = wildcard_export_target(map, &key) {
2388 return Some(target);
2389 }
2390
2391 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2392 return export_condition_target(exports);
2393 }
2394
2395 None
2396 }
2397 _ => None,
2398 }
2399}
2400
2401fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2402 for (pattern, target) in map {
2403 let Some(star_index) = pattern.find('*') else {
2404 continue;
2405 };
2406 let (prefix, suffix_with_star) = pattern.split_at(star_index);
2407 let suffix = &suffix_with_star[1..];
2408 if !key.starts_with(prefix) || !key.ends_with(suffix) {
2409 continue;
2410 }
2411 let matched = &key[prefix.len()..key.len() - suffix.len()];
2412 if let Some(target_pattern) = export_condition_target(target) {
2413 return Some(target_pattern.replace('*', matched));
2414 }
2415 }
2416 None
2417}
2418
2419fn export_condition_target(value: &Value) -> Option<String> {
2420 match value {
2421 Value::String(target) => Some(target.clone()),
2422 Value::Object(map) => ["source", "import", "module", "default", "types"]
2423 .into_iter()
2424 .find_map(|field| map.get(field).and_then(export_condition_target)),
2425 _ => None,
2426 }
2427}
2428
2429fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2430 let target = target.strip_prefix("./").unwrap_or(target);
2431 if let Some(src_relative) = target.strip_prefix("dist/") {
2434 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2435 return Some(path);
2436 }
2437 }
2438
2439 resolve_file_like_path(&package_root.join(target))
2440}
2441
2442fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2443 match subpath {
2444 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2445 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2446 None => resolve_file_like_path(&package_root.join("src").join("index"))
2447 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2448 }
2449}
2450
2451pub(crate) fn resolve_reexported_symbol_target<F, D>(
2452 file: &Path,
2453 symbol_name: &str,
2454 file_exports_symbol: &mut F,
2455 file_default_export_symbol: &mut D,
2456) -> Option<(PathBuf, String)>
2457where
2458 F: FnMut(&Path, &str) -> bool,
2459 D: FnMut(&Path) -> Option<String>,
2460{
2461 resolve_reexported_symbol(
2462 file,
2463 symbol_name,
2464 file_exports_symbol,
2465 file_default_export_symbol,
2466 )
2467 .map(|target| (target.file, target.symbol))
2468}
2469
2470fn resolve_reexported_symbol<F, D>(
2471 file: &Path,
2472 symbol_name: &str,
2473 file_exports_symbol: &mut F,
2474 file_default_export_symbol: &mut D,
2475) -> Option<ResolvedSymbol>
2476where
2477 F: FnMut(&Path, &str) -> bool,
2478 D: FnMut(&Path) -> Option<String>,
2479{
2480 let mut visited = HashSet::new();
2481 resolve_reexported_symbol_inner(
2482 file,
2483 symbol_name,
2484 file_exports_symbol,
2485 file_default_export_symbol,
2486 &mut visited,
2487 )
2488}
2489
2490fn resolve_reexported_symbol_inner<F, D>(
2491 file: &Path,
2492 symbol_name: &str,
2493 file_exports_symbol: &mut F,
2494 file_default_export_symbol: &mut D,
2495 visited: &mut HashSet<(PathBuf, String)>,
2496) -> Option<ResolvedSymbol>
2497where
2498 F: FnMut(&Path, &str) -> bool,
2499 D: FnMut(&Path) -> Option<String>,
2500{
2501 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2502 if !visited.insert((canon.clone(), symbol_name.to_string())) {
2503 return None;
2504 }
2505
2506 let source = std::fs::read_to_string(&canon).ok()?;
2507 let lang = detect_language(&canon)?;
2508 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2509 if symbol_name == "default" {
2510 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
2511 file: canon,
2512 symbol,
2513 });
2514 }
2515 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
2516 file: canon,
2517 symbol: symbol_name.to_string(),
2518 });
2519 }
2520
2521 let grammar = grammar_for(lang);
2522 let mut parser = Parser::new();
2523 parser.set_language(&grammar).ok()?;
2524 let tree = parser.parse(&source, None)?;
2525 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2526
2527 let mut cursor = tree.root_node().walk();
2528 if !cursor.goto_first_child() {
2529 return None;
2530 }
2531
2532 loop {
2533 let node = cursor.node();
2534 if node.kind() == "export_statement" {
2535 if let Some(target) = resolve_reexport_statement(
2536 &source,
2537 node,
2538 from_dir,
2539 symbol_name,
2540 file_exports_symbol,
2541 file_default_export_symbol,
2542 visited,
2543 ) {
2544 return Some(target);
2545 }
2546 }
2547
2548 if !cursor.goto_next_sibling() {
2549 break;
2550 }
2551 }
2552
2553 if symbol_name == "default" {
2554 if let Some(symbol) = file_default_export_symbol(&canon) {
2555 return Some(ResolvedSymbol {
2556 file: canon,
2557 symbol,
2558 });
2559 }
2560 }
2561
2562 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
2563 return Some(ResolvedSymbol {
2564 file: canon,
2565 symbol,
2566 });
2567 }
2568
2569 if file_exports_symbol(&canon, symbol_name) {
2570 let symbol = symbol_name.to_string();
2571 return Some(ResolvedSymbol {
2572 file: canon,
2573 symbol,
2574 });
2575 }
2576
2577 None
2578}
2579
2580fn resolve_reexport_statement<F, D>(
2581 source: &str,
2582 node: tree_sitter::Node,
2583 from_dir: &Path,
2584 symbol_name: &str,
2585 file_exports_symbol: &mut F,
2586 file_default_export_symbol: &mut D,
2587 visited: &mut HashSet<(PathBuf, String)>,
2588) -> Option<ResolvedSymbol>
2589where
2590 F: FnMut(&Path, &str) -> bool,
2591 D: FnMut(&Path) -> Option<String>,
2592{
2593 let source_node = node
2594 .child_by_field_name("source")
2595 .or_else(|| find_child_by_kind(node, "string"))?;
2596 let module_path = string_literal_content(source, source_node)?;
2597 let target_file = resolve_module_path(from_dir, &module_path)?;
2598 let raw_export = node_text(node, source);
2599
2600 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2601 return resolve_reexported_symbol_inner(
2602 &target_file,
2603 &source_symbol,
2604 file_exports_symbol,
2605 file_default_export_symbol,
2606 visited,
2607 )
2608 .or(Some(ResolvedSymbol {
2609 file: target_file,
2610 symbol: source_symbol,
2611 }));
2612 }
2613
2614 if raw_export.contains('*') {
2615 return resolve_reexported_symbol_inner(
2616 &target_file,
2617 symbol_name,
2618 file_exports_symbol,
2619 file_default_export_symbol,
2620 visited,
2621 );
2622 }
2623
2624 None
2625}
2626
2627fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
2628 let lang = detect_language(file)?;
2629 let grammar = grammar_for(lang);
2630 let mut parser = Parser::new();
2631 parser.set_language(&grammar).ok()?;
2632 let tree = parser.parse(source, None)?;
2633
2634 let mut cursor = tree.root_node().walk();
2635 if !cursor.goto_first_child() {
2636 return None;
2637 }
2638
2639 loop {
2640 let node = cursor.node();
2641 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
2642 let raw_export = node_text(node, source);
2643 if let Some(source_symbol) =
2644 reexport_clause_source_symbol(&raw_export, requested_export)
2645 {
2646 return Some(source_symbol);
2647 }
2648 }
2649
2650 if !cursor.goto_next_sibling() {
2651 break;
2652 }
2653 }
2654
2655 None
2656}
2657
2658fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
2659 let start = raw_export.find('{')? + 1;
2660 let end = raw_export[start..].find('}')? + start;
2661 for specifier in raw_export[start..end].split(',') {
2662 let specifier = specifier.trim();
2663 if specifier.is_empty() {
2664 continue;
2665 }
2666 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
2667 if let Some((imported, exported)) = specifier.split_once(" as ") {
2668 if exported.trim() == requested_export {
2669 return Some(imported.trim().to_string());
2670 }
2671 } else if specifier == requested_export {
2672 return Some(requested_export.to_string());
2673 }
2674 }
2675 None
2676}
2677
2678fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2679 let raw = source[node.byte_range()].trim();
2680 let quote = raw.chars().next()?;
2681 if quote != '\'' && quote != '"' {
2682 return None;
2683 }
2684 raw.strip_prefix(quote)
2685 .and_then(|value| value.strip_suffix(quote))
2686 .map(ToOwned::to_owned)
2687}
2688
2689fn find_index_file(dir: &Path) -> Option<PathBuf> {
2691 for name in JS_TS_INDEX_FILES {
2692 let p = dir.join(name);
2693 if p.is_file() {
2694 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2695 }
2696 }
2697 None
2698}
2699
2700fn resolve_aliased_import(
2703 local_name: &str,
2704 import_block: &ImportBlock,
2705 caller_dir: &Path,
2706) -> Option<(String, PathBuf)> {
2707 for imp in &import_block.imports {
2708 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2711 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2712 return Some((original, resolved_path));
2713 }
2714 }
2715 }
2716 None
2717}
2718
2719fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2723 let search = format!(" as {}", local_name);
2726 if let Some(pos) = raw_import.find(&search) {
2727 let before = &raw_import[..pos];
2729 let original = before
2731 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2732 .find(|s| !s.is_empty())?;
2733 return Some(original.to_string());
2734 }
2735 None
2736}
2737
2738pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2746 use ignore::WalkBuilder;
2747
2748 let walker = WalkBuilder::new(root)
2749 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .add_custom_ignore_filename(".aftignore") .filter_entry(|entry| {
2755 let name = entry.file_name().to_string_lossy();
2756 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2758 return !matches!(
2759 name.as_ref(),
2760 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2761 | ".tox" | "dist" | "build"
2762 );
2763 }
2764 true
2765 })
2766 .build();
2767
2768 walker
2769 .filter_map(|entry| entry.ok())
2770 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2771 .filter(|entry| detect_language(entry.path()).is_some())
2772 .map(|entry| entry.into_path())
2773}
2774
2775#[cfg(test)]
2780mod tests {
2781 use super::*;
2782 use std::fs;
2783 use tempfile::TempDir;
2784
2785 fn collect_calls_by_symbol_reference(
2786 source: &str,
2787 root: Node<'_>,
2788 lang: LangId,
2789 symbols: &[Symbol],
2790 ) -> HashMap<String, Vec<CallSite>> {
2791 let mut calls_by_symbol = HashMap::new();
2792 for symbol in symbols {
2793 let byte_start =
2794 line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col);
2795 let byte_end = line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col);
2796 let sites = extract_calls_full(source, root, byte_start, byte_end, lang)
2797 .into_iter()
2798 .map(
2799 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2800 callee_name: short,
2801 full_callee: full,
2802 line,
2803 byte_start: call_byte_start,
2804 byte_end: call_byte_end,
2805 },
2806 )
2807 .collect::<Vec<_>>();
2808 if !sites.is_empty() {
2809 calls_by_symbol.insert(symbol_identity(symbol), sites);
2810 }
2811 }
2812
2813 let symbol_ranges = symbols
2814 .iter()
2815 .map(|symbol| {
2816 (
2817 line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col),
2818 line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col),
2819 )
2820 })
2821 .collect::<Vec<_>>();
2822 let top_level_sites = collect_calls_full_with_ranges(root, source, 0, source.len(), lang)
2823 .into_iter()
2824 .filter(|site| {
2825 !symbol_ranges
2826 .iter()
2827 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2828 })
2829 .map(|site| CallSite {
2830 callee_name: site.short,
2831 full_callee: site.full,
2832 line: site.line,
2833 byte_start: site.byte_start,
2834 byte_end: site.byte_end,
2835 })
2836 .collect::<Vec<_>>();
2837 if !top_level_sites.is_empty() {
2838 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2839 }
2840 calls_by_symbol
2841 }
2842
2843 fn parse_symbols(source: &str, lang: LangId) -> (tree_sitter::Tree, Vec<Symbol>) {
2844 let mut parser = Parser::new();
2845 parser.set_language(&grammar_for(lang)).unwrap();
2846 let tree = parser.parse(source, None).unwrap();
2847 let symbols = crate::parser::extract_symbols_from_tree(source, &tree, lang).unwrap();
2848 (tree, symbols)
2849 }
2850
2851 fn test_symbol(name: &str, start_col: u32, end_col: u32) -> Symbol {
2852 Symbol {
2853 name: name.to_string(),
2854 kind: SymbolKind::Function,
2855 range: Range {
2856 start_line: 0,
2857 start_col,
2858 end_line: 0,
2859 end_col,
2860 },
2861 signature: None,
2862 scope_chain: Vec::new(),
2863 exported: false,
2864 parent: None,
2865 }
2866 }
2867
2868 #[test]
2869 fn source_line_index_matches_shared_line_column_conversion() {
2870 let source = "a\r\nbb\rc\n";
2871 let index = SourceLineIndex::new(source);
2872 for line in 0..=5 {
2873 for column in 0..=5 {
2874 assert_eq!(
2875 index.byte_offset(line, column),
2876 line_col_to_byte(source, line, column),
2877 "line={line}, column={column}"
2878 );
2879 }
2880 }
2881 }
2882
2883 #[test]
2884 fn single_pass_call_attribution_matches_per_symbol_reference() {
2885 let corpora = [
2886 (
2887 "typescript",
2888 LangId::TypeScript,
2889 r#"bootstrap();
2890class Worker {
2891 run() {
2892 before();
2893 function nested() { nestedCall(); }
2894 nested();
2895 }
2896 next() { adjacentCall(); }
2897}
2898function left() { leftCall(); }
2899function right() { rightCall(); }
2900"#,
2901 ),
2902 (
2903 "python",
2904 LangId::Python,
2905 r#"bootstrap()
2906class Worker:
2907 def run(self):
2908 before()
2909 def nested():
2910 nested_call()
2911 nested()
2912
2913 def next(self):
2914 adjacent_call()
2915
2916def left():
2917 left_call()
2918
2919def right():
2920 right_call()
2921"#,
2922 ),
2923 ];
2924
2925 for (name, lang, source) in corpora {
2926 let (tree, symbols) = parse_symbols(source, lang);
2927 let reference =
2928 collect_calls_by_symbol_reference(source, tree.root_node(), lang, &symbols);
2929 let actual = collect_calls_by_symbol(source, tree.root_node(), lang, &symbols);
2930 assert_eq!(actual, reference, "call attribution changed for {name}");
2931
2932 let class_sites = actual.get("Worker").expect("class receives method calls");
2933 let method_sites = actual
2934 .get("Worker::run")
2935 .expect("method receives its own calls");
2936 assert!(class_sites.iter().any(|site| site.callee_name == "before"));
2937 assert!(method_sites.iter().any(|site| site.callee_name == "before"));
2938 let nested_sites = actual
2939 .iter()
2940 .find(|(symbol, _)| symbol.rsplit("::").next() == Some("nested"))
2941 .map(|(_, sites)| sites)
2942 .expect("nested function receives its own calls");
2943 assert!(nested_sites.iter().any(|site| {
2944 site.callee_name == "nested_call" || site.callee_name == "nestedCall"
2945 }));
2946 assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "bootstrap");
2947 }
2948
2949 let source = "first();second();third();";
2950 let (tree, _) = parse_symbols(source, LangId::TypeScript);
2951 let symbols = vec![
2952 test_symbol("outer", 0, 17),
2953 test_symbol("left", 0, 8),
2954 test_symbol("right", 8, 17),
2955 test_symbol("empty", 24, 24),
2956 ];
2957 let reference = collect_calls_by_symbol_reference(
2958 source,
2959 tree.root_node(),
2960 LangId::TypeScript,
2961 &symbols,
2962 );
2963 let actual =
2964 collect_calls_by_symbol(source, tree.root_node(), LangId::TypeScript, &symbols);
2965 assert_eq!(actual, reference, "overlapping and adjacent ranges changed");
2966 assert_eq!(
2967 actual["outer"]
2968 .iter()
2969 .map(|site| site.callee_name.as_str())
2970 .collect::<Vec<_>>(),
2971 ["first", "second"]
2972 );
2973 assert_eq!(actual["left"][0].callee_name, "first");
2974 assert_eq!(actual["right"][0].callee_name, "second");
2975 assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "third");
2976 assert!(!actual.contains_key("empty"));
2977 }
2978
2979 #[test]
2980 fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
2981 let mut symbol_metadata = HashMap::new();
2986 symbol_metadata.insert(
2987 "BackupStore::total_disk_bytes".to_string(),
2988 SymbolMeta {
2989 kind: SymbolKind::Method,
2990 exported: true,
2991 signature: None,
2992 line: 703,
2993 range: Range {
2994 start_line: 702,
2995 start_col: 0,
2996 end_line: 705,
2997 end_col: 0,
2998 },
2999 entry_point_attribute: None,
3000 },
3001 );
3002 let file_data = FileCallData {
3003 calls_by_symbol: HashMap::new(),
3004 value_refs_by_symbol: HashMap::new(),
3005 exported_symbols: vec!["total_disk_bytes".to_string()],
3006 symbol_metadata,
3007 default_export_symbol: None,
3008 import_block: ImportBlock::empty(),
3009 lang: LangId::Rust,
3010 };
3011
3012 let meta = file_data
3013 .symbol_metadata_for("total_disk_bytes")
3014 .expect("scoped method recovered by bare name");
3015 assert_eq!(meta.kind, SymbolKind::Method);
3016 assert_eq!(
3017 meta.line, 703,
3018 "real declaration line, not the line-1 fallback"
3019 );
3020
3021 assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
3023 }
3024
3025 fn setup_ts_project() -> TempDir {
3027 let dir = TempDir::new().unwrap();
3028
3029 fs::write(
3031 dir.path().join("main.ts"),
3032 r#"import { helper, compute } from './utils';
3033import * as math from './math';
3034
3035export function main() {
3036 const a = helper(1);
3037 const b = compute(a, 2);
3038 const c = math.add(a, b);
3039 return c;
3040}
3041"#,
3042 )
3043 .unwrap();
3044
3045 fs::write(
3047 dir.path().join("utils.ts"),
3048 r#"import { double } from './helpers';
3049
3050export function helper(x: number): number {
3051 return double(x);
3052}
3053
3054export function compute(a: number, b: number): number {
3055 return a + b;
3056}
3057"#,
3058 )
3059 .unwrap();
3060
3061 fs::write(
3063 dir.path().join("helpers.ts"),
3064 r#"export function double(x: number): number {
3065 return x * 2;
3066}
3067
3068export function triple(x: number): number {
3069 return x * 3;
3070}
3071"#,
3072 )
3073 .unwrap();
3074
3075 fs::write(
3077 dir.path().join("math.ts"),
3078 r#"export function add(a: number, b: number): number {
3079 return a + b;
3080}
3081
3082export function subtract(a: number, b: number): number {
3083 return a - b;
3084}
3085"#,
3086 )
3087 .unwrap();
3088
3089 dir
3090 }
3091
3092 fn setup_alias_project() -> TempDir {
3094 let dir = TempDir::new().unwrap();
3095
3096 fs::write(
3097 dir.path().join("main.ts"),
3098 r#"import { helper as h } from './utils';
3099
3100export function main() {
3101 return h(42);
3102}
3103"#,
3104 )
3105 .unwrap();
3106
3107 fs::write(
3108 dir.path().join("utils.ts"),
3109 r#"export function helper(x: number): number {
3110 return x + 1;
3111}
3112"#,
3113 )
3114 .unwrap();
3115
3116 dir
3117 }
3118
3119 #[test]
3122 fn callgraph_single_file_call_extraction() {
3123 let dir = setup_ts_project();
3124 let mut graph = CallGraph::new(dir.path().to_path_buf());
3125
3126 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3127 let main_calls = &file_data.calls_by_symbol["main"];
3128
3129 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3130 assert!(
3131 callee_names.contains(&"helper"),
3132 "main should call helper, got: {:?}",
3133 callee_names
3134 );
3135 assert!(
3136 callee_names.contains(&"compute"),
3137 "main should call compute, got: {:?}",
3138 callee_names
3139 );
3140 assert!(
3141 callee_names.contains(&"add"),
3142 "main should call math.add (short name: add), got: {:?}",
3143 callee_names
3144 );
3145 }
3146
3147 #[test]
3148 fn callgraph_file_data_has_exports() {
3149 let dir = setup_ts_project();
3150 let mut graph = CallGraph::new(dir.path().to_path_buf());
3151
3152 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3153 assert!(
3154 file_data.exported_symbols.contains(&"helper".to_string()),
3155 "utils.ts should export helper, got: {:?}",
3156 file_data.exported_symbols
3157 );
3158 assert!(
3159 file_data.exported_symbols.contains(&"compute".to_string()),
3160 "utils.ts should export compute, got: {:?}",
3161 file_data.exported_symbols
3162 );
3163 }
3164
3165 #[test]
3168 fn callgraph_resolve_direct_import() {
3169 let dir = setup_ts_project();
3170 let mut graph = CallGraph::new(dir.path().to_path_buf());
3171
3172 let main_path = dir.path().join("main.ts");
3173 let file_data = graph.build_file(&main_path).unwrap();
3174 let import_block = file_data.import_block.clone();
3175
3176 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3177 match edge {
3178 EdgeResolution::Resolved { file, symbol } => {
3179 assert!(
3180 file.ends_with("utils.ts"),
3181 "helper should resolve to utils.ts, got: {:?}",
3182 file
3183 );
3184 assert_eq!(symbol, "helper");
3185 }
3186 EdgeResolution::Unresolved { callee_name } => {
3187 panic!("Expected resolved, got unresolved: {}", callee_name);
3188 }
3189 }
3190 }
3191
3192 #[test]
3193 fn callgraph_resolve_namespace_import() {
3194 let dir = setup_ts_project();
3195 let mut graph = CallGraph::new(dir.path().to_path_buf());
3196
3197 let main_path = dir.path().join("main.ts");
3198 let file_data = graph.build_file(&main_path).unwrap();
3199 let import_block = file_data.import_block.clone();
3200
3201 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3202 match edge {
3203 EdgeResolution::Resolved { file, symbol } => {
3204 assert!(
3205 file.ends_with("math.ts"),
3206 "math.add should resolve to math.ts, got: {:?}",
3207 file
3208 );
3209 assert_eq!(symbol, "add");
3210 }
3211 EdgeResolution::Unresolved { callee_name } => {
3212 panic!("Expected resolved, got unresolved: {}", callee_name);
3213 }
3214 }
3215 }
3216
3217 #[test]
3218 fn callgraph_resolve_aliased_import() {
3219 let dir = setup_alias_project();
3220 let mut graph = CallGraph::new(dir.path().to_path_buf());
3221
3222 let main_path = dir.path().join("main.ts");
3223 let file_data = graph.build_file(&main_path).unwrap();
3224 let import_block = file_data.import_block.clone();
3225
3226 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3227 match edge {
3228 EdgeResolution::Resolved { file, symbol } => {
3229 assert!(
3230 file.ends_with("utils.ts"),
3231 "h (alias for helper) should resolve to utils.ts, got: {:?}",
3232 file
3233 );
3234 assert_eq!(symbol, "helper");
3235 }
3236 EdgeResolution::Unresolved { callee_name } => {
3237 panic!("Expected resolved, got unresolved: {}", callee_name);
3238 }
3239 }
3240 }
3241
3242 #[test]
3243 fn callgraph_unresolved_edge_marked() {
3244 let dir = setup_ts_project();
3245 let mut graph = CallGraph::new(dir.path().to_path_buf());
3246
3247 let main_path = dir.path().join("main.ts");
3248 let file_data = graph.build_file(&main_path).unwrap();
3249 let import_block = file_data.import_block.clone();
3250
3251 let edge =
3252 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3253 assert_eq!(
3254 edge,
3255 EdgeResolution::Unresolved {
3256 callee_name: "unknownFunc".to_string()
3257 },
3258 "Unknown callee should be unresolved"
3259 );
3260 }
3261
3262 #[test]
3265 fn callgraph_walker_excludes_gitignored() {
3266 let dir = TempDir::new().unwrap();
3267
3268 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3270
3271 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3273 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3274 fs::write(
3275 dir.path().join("ignored_dir").join("secret.ts"),
3276 "export function secret() {}",
3277 )
3278 .unwrap();
3279
3280 fs::create_dir(dir.path().join("node_modules")).unwrap();
3282 fs::write(
3283 dir.path().join("node_modules").join("dep.ts"),
3284 "export function dep() {}",
3285 )
3286 .unwrap();
3287
3288 let mut command = std::process::Command::new("git");
3290 crate::test_env::apply_hermetic_git_env(command.current_dir(dir.path()))
3291 .args(["init"])
3292 .output()
3293 .unwrap();
3294
3295 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3296 let file_names: Vec<String> = files
3297 .iter()
3298 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3299 .collect();
3300
3301 assert!(
3302 file_names.contains(&"main.ts".to_string()),
3303 "Should include main.ts, got: {:?}",
3304 file_names
3305 );
3306 assert!(
3307 !file_names.contains(&"secret.ts".to_string()),
3308 "Should exclude gitignored secret.ts, got: {:?}",
3309 file_names
3310 );
3311 assert!(
3312 !file_names.contains(&"dep.ts".to_string()),
3313 "Should exclude node_modules, got: {:?}",
3314 file_names
3315 );
3316 }
3317
3318 #[test]
3319 fn callgraph_walker_excludes_aftignored() {
3320 let dir = TempDir::new().unwrap();
3321
3322 fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
3324 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3325 fs::create_dir(dir.path().join("vendored")).unwrap();
3326 fs::write(
3327 dir.path().join("vendored").join("sub.ts"),
3328 "export function sub() {}",
3329 )
3330 .unwrap();
3331
3332 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3333 let file_names: Vec<String> = files
3334 .iter()
3335 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3336 .collect();
3337
3338 assert!(
3339 file_names.contains(&"main.ts".to_string()),
3340 "Should include main.ts, got: {:?}",
3341 file_names
3342 );
3343 assert!(
3344 !file_names.contains(&"sub.ts".to_string()),
3345 "Should exclude .aftignored sub.ts, got: {:?}",
3346 file_names
3347 );
3348 }
3349
3350 #[test]
3351 fn callgraph_walker_only_source_files() {
3352 let dir = TempDir::new().unwrap();
3353
3354 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3355 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
3356 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
3357 fs::write(
3358 dir.path().join("runtime.mjs"),
3359 "export function runtime() {}",
3360 )
3361 .unwrap();
3362 fs::write(
3363 dir.path().join("legacy.cjs"),
3364 "exports.legacy = function() {};",
3365 )
3366 .unwrap();
3367 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
3368 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3369 fs::write(dir.path().join("data.json"), "{}").unwrap();
3370
3371 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3372 let file_names: Vec<String> = files
3373 .iter()
3374 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3375 .collect();
3376
3377 assert!(file_names.contains(&"main.ts".to_string()));
3378 for modern_ext_file in [
3379 "module.mts",
3380 "common.cts",
3381 "runtime.mjs",
3382 "legacy.cjs",
3383 "types.pyi",
3384 ] {
3385 assert!(
3386 file_names.contains(&modern_ext_file.to_string()),
3387 "walker should include {modern_ext_file}, got: {:?}",
3388 file_names
3389 );
3390 }
3391 assert!(
3392 file_names.contains(&"readme.md".to_string()),
3393 "Markdown is now a supported source language"
3394 );
3395 assert!(
3396 file_names.contains(&"data.json".to_string()),
3397 "JSON is now a supported source language"
3398 );
3399 }
3400
3401 #[test]
3404 fn callgraph_find_alias_original_simple() {
3405 let raw = "import { foo as bar } from './utils';";
3406 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3407 }
3408
3409 #[test]
3410 fn callgraph_find_alias_original_multiple() {
3411 let raw = "import { foo as bar, baz as qux } from './utils';";
3412 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3413 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3414 }
3415
3416 #[test]
3417 fn callgraph_find_alias_no_match() {
3418 let raw = "import { foo } from './utils';";
3419 assert_eq!(find_alias_original(raw, "foo"), None);
3420 }
3421
3422 #[test]
3425 fn is_entry_point_exported_function() {
3426 assert!(is_entry_point(
3427 "handleRequest",
3428 &SymbolKind::Function,
3429 true,
3430 LangId::TypeScript
3431 ));
3432 }
3433
3434 #[test]
3435 fn is_entry_point_exported_method_is_not_entry() {
3436 assert!(!is_entry_point(
3438 "handleRequest",
3439 &SymbolKind::Method,
3440 true,
3441 LangId::TypeScript
3442 ));
3443 }
3444
3445 #[test]
3446 fn is_entry_point_main_init_patterns() {
3447 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3448 assert!(
3449 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3450 "{} should be an entry point",
3451 name
3452 );
3453 }
3454 }
3455
3456 #[test]
3457 fn is_entry_point_test_patterns_ts() {
3458 assert!(is_entry_point(
3459 "describe",
3460 &SymbolKind::Function,
3461 false,
3462 LangId::TypeScript
3463 ));
3464 assert!(is_entry_point(
3465 "it",
3466 &SymbolKind::Function,
3467 false,
3468 LangId::TypeScript
3469 ));
3470 assert!(is_entry_point(
3471 "test",
3472 &SymbolKind::Function,
3473 false,
3474 LangId::TypeScript
3475 ));
3476 assert!(is_entry_point(
3477 "testValidation",
3478 &SymbolKind::Function,
3479 false,
3480 LangId::TypeScript
3481 ));
3482 assert!(is_entry_point(
3483 "specHelper",
3484 &SymbolKind::Function,
3485 false,
3486 LangId::TypeScript
3487 ));
3488 }
3489
3490 #[test]
3491 fn is_entry_point_test_patterns_python() {
3492 assert!(is_entry_point(
3493 "test_login",
3494 &SymbolKind::Function,
3495 false,
3496 LangId::Python
3497 ));
3498 assert!(is_entry_point(
3499 "setUp",
3500 &SymbolKind::Function,
3501 false,
3502 LangId::Python
3503 ));
3504 assert!(is_entry_point(
3505 "tearDown",
3506 &SymbolKind::Function,
3507 false,
3508 LangId::Python
3509 ));
3510 assert!(!is_entry_point(
3512 "testSomething",
3513 &SymbolKind::Function,
3514 false,
3515 LangId::Python
3516 ));
3517 }
3518
3519 #[test]
3520 fn is_entry_point_test_patterns_rust() {
3521 assert!(is_entry_point(
3522 "test_parse",
3523 &SymbolKind::Function,
3524 false,
3525 LangId::Rust
3526 ));
3527 assert!(!is_entry_point(
3528 "TestSomething",
3529 &SymbolKind::Function,
3530 false,
3531 LangId::Rust
3532 ));
3533 }
3534
3535 #[test]
3536 fn is_entry_point_test_patterns_go() {
3537 assert!(is_entry_point(
3538 "TestParsing",
3539 &SymbolKind::Function,
3540 false,
3541 LangId::Go
3542 ));
3543 assert!(!is_entry_point(
3545 "testParsing",
3546 &SymbolKind::Function,
3547 false,
3548 LangId::Go
3549 ));
3550 }
3551
3552 #[test]
3553 fn is_entry_point_non_exported_non_main_is_not_entry() {
3554 assert!(!is_entry_point(
3555 "helperUtil",
3556 &SymbolKind::Function,
3557 false,
3558 LangId::TypeScript
3559 ));
3560 }
3561
3562 #[test]
3565 fn callgraph_symbol_metadata_populated() {
3566 let dir = setup_ts_project();
3567 let mut graph = CallGraph::new(dir.path().to_path_buf());
3568
3569 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3570 assert!(
3571 file_data.symbol_metadata.contains_key("helper"),
3572 "symbol_metadata should contain helper"
3573 );
3574 let meta = &file_data.symbol_metadata["helper"];
3575 assert_eq!(meta.kind, SymbolKind::Function);
3576 assert!(meta.exported, "helper should be exported");
3577 }
3578
3579 #[test]
3580 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
3581 let dir = TempDir::new().unwrap();
3582 fs::write(
3583 dir.path().join("main.ts"),
3584 r#"import * as lib from './index';
3585
3586export function main() {
3587 lib.helper();
3588 lib.hidden();
3589}
3590"#,
3591 )
3592 .unwrap();
3593 fs::write(
3594 dir.path().join("index.ts"),
3595 "export { helper } from './utils';\n",
3596 )
3597 .unwrap();
3598 fs::write(
3599 dir.path().join("utils.ts"),
3600 r#"export function helper() {}
3601function hidden() {}
3602"#,
3603 )
3604 .unwrap();
3605
3606 let mut graph = CallGraph::new(dir.path().to_path_buf());
3607 let main_path = dir.path().join("main.ts");
3608 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
3609
3610 let helper =
3611 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
3612 match helper {
3613 EdgeResolution::Resolved { file, symbol } => {
3614 assert!(
3615 file.ends_with("utils.ts"),
3616 "helper should resolve through barrel: {file:?}"
3617 );
3618 assert_eq!(symbol, "helper");
3619 }
3620 other => panic!("expected helper to resolve through barrel, got {other:?}"),
3621 }
3622
3623 let hidden =
3624 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
3625 assert_eq!(
3626 hidden,
3627 EdgeResolution::Unresolved {
3628 callee_name: "hidden".to_string()
3629 }
3630 );
3631 }
3632
3633 #[test]
3634 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
3635 let dir = TempDir::new().unwrap();
3636 fs::write(
3637 dir.path().join("package.json"),
3638 r#"{"workspaces":["packages/*"]}"#,
3639 )
3640 .unwrap();
3641 let package_dir = dir.path().join("packages/lib");
3642 fs::create_dir_all(package_dir.join("src")).unwrap();
3643 fs::create_dir_all(package_dir.join("dist")).unwrap();
3644 fs::write(
3645 package_dir.join("package.json"),
3646 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
3647 )
3648 .unwrap();
3649 fs::write(
3650 package_dir.join("src/index.mts"),
3651 "export function helper() {}\n",
3652 )
3653 .unwrap();
3654 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
3655
3656 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
3657 assert!(
3658 resolved.ends_with("src/index.mts"),
3659 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
3660 );
3661 }
3662
3663 #[test]
3664 fn same_named_methods_use_scoped_symbol_identity() {
3665 let dir = TempDir::new().unwrap();
3666 fs::write(
3667 dir.path().join("classes.ts"),
3668 r#"class A {
3669 run() { helperA(); }
3670}
3671
3672class B {
3673 run() { helperB(); }
3674}
3675
3676function helperA() {}
3677function helperB() {}
3678"#,
3679 )
3680 .unwrap();
3681
3682 let mut graph = CallGraph::new(dir.path().to_path_buf());
3683 let path = dir.path().join("classes.ts");
3684 let data = graph.build_file(&path).unwrap();
3685
3686 assert!(
3687 data.symbol_metadata.contains_key("A::run"),
3688 "A::run metadata missing"
3689 );
3690 assert!(
3691 data.symbol_metadata.contains_key("B::run"),
3692 "B::run metadata missing"
3693 );
3694 assert!(
3695 data.calls_by_symbol["A::run"]
3696 .iter()
3697 .any(|call| call.callee_name == "helperA"),
3698 "A::run calls should not be overwritten"
3699 );
3700 assert!(
3701 data.calls_by_symbol["B::run"]
3702 .iter()
3703 .any(|call| call.callee_name == "helperB"),
3704 "B::run calls should not be overwritten"
3705 );
3706 }
3707
3708 #[test]
3711 fn extract_parameters_typescript() {
3712 let params = extract_parameters(
3713 "function processData(input: string, count: number): void",
3714 LangId::TypeScript,
3715 );
3716 assert_eq!(params, vec!["input", "count"]);
3717 }
3718
3719 #[test]
3720 fn extract_parameters_typescript_optional() {
3721 let params = extract_parameters(
3722 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3723 LangId::TypeScript,
3724 );
3725 assert_eq!(params, vec!["url", "options"]);
3726 }
3727
3728 #[test]
3729 fn extract_parameters_typescript_defaults() {
3730 let params = extract_parameters(
3731 "function greet(name: string, greeting: string = \"hello\"): string",
3732 LangId::TypeScript,
3733 );
3734 assert_eq!(params, vec!["name", "greeting"]);
3735 }
3736
3737 #[test]
3738 fn extract_parameters_typescript_rest() {
3739 let params = extract_parameters(
3740 "function sum(...numbers: number[]): number",
3741 LangId::TypeScript,
3742 );
3743 assert_eq!(params, vec!["numbers"]);
3744 }
3745
3746 #[test]
3747 fn extract_parameters_python_self_skipped() {
3748 let params = extract_parameters(
3749 "def process(self, data: str, count: int) -> bool",
3750 LangId::Python,
3751 );
3752 assert_eq!(params, vec!["data", "count"]);
3753 }
3754
3755 #[test]
3756 fn extract_parameters_python_no_self() {
3757 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3758 assert_eq!(params, vec!["input"]);
3759 }
3760
3761 #[test]
3762 fn extract_parameters_python_star_args() {
3763 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3764 assert_eq!(params, vec!["args", "kwargs"]);
3765 }
3766
3767 #[test]
3768 fn extract_parameters_rust_self_skipped() {
3769 let params = extract_parameters(
3770 "fn process(&self, data: &str, count: usize) -> bool",
3771 LangId::Rust,
3772 );
3773 assert_eq!(params, vec!["data", "count"]);
3774 }
3775
3776 #[test]
3777 fn extract_parameters_rust_mut_self_skipped() {
3778 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3779 assert_eq!(params, vec!["value"]);
3780 }
3781
3782 #[test]
3783 fn extract_parameters_rust_no_self() {
3784 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3785 assert_eq!(params, vec!["input"]);
3786 }
3787
3788 #[test]
3789 fn extract_parameters_rust_mut_param() {
3790 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3791 assert_eq!(params, vec!["buf", "len"]);
3792 }
3793
3794 #[test]
3795 fn extract_parameters_go() {
3796 let params = extract_parameters(
3797 "func ProcessData(input string, count int) error",
3798 LangId::Go,
3799 );
3800 assert_eq!(params, vec!["input", "count"]);
3801 }
3802
3803 #[test]
3804 fn extract_parameters_empty() {
3805 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3806 assert!(
3807 params.is_empty(),
3808 "no-arg function should return empty params"
3809 );
3810 }
3811
3812 #[test]
3813 fn extract_parameters_no_parens() {
3814 let params = extract_parameters("const x = 42", LangId::TypeScript);
3815 assert!(params.is_empty(), "no parens should return empty params");
3816 }
3817
3818 #[test]
3819 fn extract_parameters_javascript() {
3820 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3821 assert_eq!(params, vec!["event", "target"]);
3822 }
3823}