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