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