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_cross_file_edge(
787 &mut self,
788 full_callee: &str,
789 short_name: &str,
790 caller_file: &Path,
791 import_block: &ImportBlock,
792 ) -> EdgeResolution {
793 let graph = RefCell::new(self);
794 Self::resolve_cross_file_edge_with_exports(
795 full_callee,
796 short_name,
797 caller_file,
798 import_block,
799 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
800 |path| graph.borrow_mut().file_default_export_symbol(path),
801 )
802 }
803
804 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
806 match self.build_file(path) {
807 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
808 Err(_) => false,
809 }
810 }
811
812 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
813 self.build_file(path)
814 .ok()
815 .and_then(|data| data.default_export_symbol.clone())
816 }
817
818 pub fn invalidate_file(&mut self, path: &Path) {
820 self.data.remove(path);
822 if let Ok(canon) = self.canonicalize(path) {
823 self.data.remove(&canon);
824 }
825 clear_workspace_package_cache();
826 }
827
828 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
830 let full_path = if path.is_relative() {
832 self.project_root.join(path)
833 } else {
834 path.to_path_buf()
835 };
836
837 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
839 }
840}
841
842pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
848 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
849 message: format!("unsupported file for call graph: {}", path.display()),
850 })?;
851
852 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
853 path: format!("{}: {}", path.display(), e),
854 })?;
855
856 build_file_data_from_source_with_lang(path, &source, lang)
857}
858
859pub(crate) fn build_file_data_from_source(
860 path: &Path,
861 source: &str,
862) -> Result<FileCallData, AftError> {
863 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
864 message: format!("unsupported file for call graph: {}", path.display()),
865 })?;
866 build_file_data_from_source_with_lang(path, source, lang)
867}
868
869fn build_file_data_from_source_with_lang(
870 path: &Path,
871 source: &str,
872 lang: LangId,
873) -> Result<FileCallData, AftError> {
874 let grammar = grammar_for(lang);
875 let mut parser = Parser::new();
876 parser
877 .set_language(&grammar)
878 .map_err(|e| AftError::ParseError {
879 message: format!("grammar init failed for {:?}: {}", lang, e),
880 })?;
881
882 let tree = parser
883 .parse(&source, None)
884 .ok_or_else(|| AftError::ParseError {
885 message: format!("parse failed for {}", path.display()),
886 })?;
887
888 let import_block = imports::parse_imports(&source, &tree, lang);
890
891 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
893
894 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
896 let root = tree.root_node();
897
898 for sym in &symbols {
899 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
900 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
901
902 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
903
904 let sites: Vec<CallSite> = raw_calls
905 .into_iter()
906 .map(
907 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
908 callee_name: short,
909 full_callee: full,
910 line,
911 byte_start: call_byte_start,
912 byte_end: call_byte_end,
913 },
914 )
915 .collect();
916
917 if !sites.is_empty() {
918 calls_by_symbol.insert(symbol_identity(sym), sites);
919 }
920 }
921
922 let symbol_ranges: Vec<(usize, usize)> = symbols
923 .iter()
924 .map(|sym| {
925 (
926 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
927 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
928 )
929 })
930 .collect();
931
932 let top_level_sites: Vec<CallSite> =
933 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
934 .into_iter()
935 .filter(|site| {
936 !symbol_ranges
937 .iter()
938 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
939 })
940 .map(|site| CallSite {
941 callee_name: site.short,
942 full_callee: site.full,
943 line: site.line,
944 byte_start: site.byte_start,
945 byte_end: site.byte_end,
946 })
947 .collect();
948
949 if !top_level_sites.is_empty() {
950 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
951 }
952
953 let default_export = find_default_export(&source, root, path, lang);
954
955 if let Some(default_export) = &default_export {
956 if default_export.synthetic {
957 let byte_start = default_export.node.byte_range().start;
958 let byte_end = default_export.node.byte_range().end;
959 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
960 let sites: Vec<CallSite> = raw_calls
961 .into_iter()
962 .filter(|(_, short, _, _, _)| *short != default_export.symbol)
963 .map(
964 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
965 callee_name: short,
966 full_callee: full,
967 line,
968 byte_start: call_byte_start,
969 byte_end: call_byte_end,
970 },
971 )
972 .collect();
973 if !sites.is_empty() {
974 calls_by_symbol.insert(default_export.symbol.clone(), sites);
975 }
976 }
977 }
978
979 let mut exported_symbols: Vec<String> = symbols
981 .iter()
982 .filter(|s| s.exported)
983 .map(|s| s.name.clone())
984 .collect();
985 if let Some(default_export) = &default_export {
986 if !exported_symbols
987 .iter()
988 .any(|name| name == &default_export.symbol)
989 {
990 exported_symbols.push(default_export.symbol.clone());
991 }
992 }
993
994 let rust_attribute_entry_points = if lang == LangId::Rust {
995 crate::parser::rust_attribute_entry_points(&source, root)
996 .into_iter()
997 .map(|entry| (entry.scoped_name, entry.attribute.to_string()))
998 .collect::<HashMap<_, _>>()
999 } else {
1000 HashMap::new()
1001 };
1002
1003 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
1005 .iter()
1006 .map(|s| {
1007 let identity = symbol_identity(s);
1008 (
1009 identity.clone(),
1010 SymbolMeta {
1011 kind: s.kind.clone(),
1012 exported: s.exported,
1013 signature: s.signature.clone(),
1014 line: s.range.start_line + 1,
1015 range: s.range.clone(),
1016 entry_point_attribute: rust_attribute_entry_points.get(&identity).cloned(),
1017 },
1018 )
1019 })
1020 .collect();
1021 if let Some(default_export) = &default_export {
1022 symbol_metadata
1023 .entry(default_export.symbol.clone())
1024 .or_insert_with(|| SymbolMeta {
1025 kind: default_export.kind.clone(),
1026 exported: true,
1027 signature: Some(first_line_signature(&source, &default_export.node)),
1028 line: default_export.node.start_position().row as u32 + 1,
1029 range: crate::parser::node_range(&default_export.node),
1030 entry_point_attribute: None,
1031 });
1032 }
1033 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
1034 symbol_metadata
1035 .entry(TOP_LEVEL_SYMBOL.to_string())
1036 .or_insert(SymbolMeta {
1037 kind: SymbolKind::Function,
1038 exported: false,
1039 signature: None,
1040 line: 1,
1041 range: Range {
1042 start_line: 0,
1043 start_col: 0,
1044 end_line: 0,
1045 end_col: 0,
1046 },
1047 entry_point_attribute: None,
1048 });
1049 }
1050
1051 Ok(FileCallData {
1052 calls_by_symbol,
1053 exported_symbols,
1054 symbol_metadata,
1055 default_export_symbol: default_export.map(|export| export.symbol),
1056 import_block,
1057 lang,
1058 })
1059}
1060
1061#[derive(Debug, Clone)]
1062struct DefaultExport<'tree> {
1063 symbol: String,
1064 synthetic: bool,
1065 kind: SymbolKind,
1066 node: Node<'tree>,
1067}
1068
1069fn find_default_export<'tree>(
1070 source: &str,
1071 root: Node<'tree>,
1072 path: &Path,
1073 lang: LangId,
1074) -> Option<DefaultExport<'tree>> {
1075 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
1076 return None;
1077 }
1078 find_default_export_inner(source, root, path)
1079}
1080
1081fn find_default_export_inner<'tree>(
1082 source: &str,
1083 node: Node<'tree>,
1084 path: &Path,
1085) -> Option<DefaultExport<'tree>> {
1086 if node.kind() == "export_statement" {
1087 if let Some(default_export) = default_export_from_statement(source, node, path) {
1088 return Some(default_export);
1089 }
1090 }
1091
1092 let mut cursor = node.walk();
1093 if !cursor.goto_first_child() {
1094 return None;
1095 }
1096
1097 loop {
1098 let child = cursor.node();
1099 if let Some(default_export) = find_default_export_inner(source, child, path) {
1100 return Some(default_export);
1101 }
1102 if !cursor.goto_next_sibling() {
1103 break;
1104 }
1105 }
1106
1107 None
1108}
1109
1110fn default_export_from_statement<'tree>(
1111 source: &str,
1112 node: Node<'tree>,
1113 path: &Path,
1114) -> Option<DefaultExport<'tree>> {
1115 let mut cursor = node.walk();
1116 if !cursor.goto_first_child() {
1117 return None;
1118 }
1119
1120 let mut saw_default = false;
1121 loop {
1122 let child = cursor.node();
1123 match child.kind() {
1124 "default" => saw_default = true,
1125 "function_declaration" | "generator_function_declaration" | "class_declaration"
1126 if saw_default =>
1127 {
1128 if let Some(name_node) = child.child_by_field_name("name") {
1129 return Some(DefaultExport {
1130 symbol: source[name_node.byte_range()].to_string(),
1131 synthetic: false,
1132 kind: default_export_kind(&child),
1133 node: child,
1134 });
1135 }
1136 return Some(DefaultExport {
1137 symbol: synthetic_default_symbol(path),
1138 synthetic: true,
1139 kind: default_export_kind(&child),
1140 node: child,
1141 });
1142 }
1143 "arrow_function"
1144 | "function"
1145 | "function_expression"
1146 | "class"
1147 | "class_expression"
1148 if saw_default =>
1149 {
1150 return Some(DefaultExport {
1151 symbol: synthetic_default_symbol(path),
1152 synthetic: true,
1153 kind: default_export_kind(&child),
1154 node: child,
1155 });
1156 }
1157 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
1158 return Some(DefaultExport {
1159 symbol: source[child.byte_range()].to_string(),
1160 synthetic: false,
1161 kind: SymbolKind::Function,
1162 node: child,
1163 });
1164 }
1165 _ => {}
1166 }
1167 if !cursor.goto_next_sibling() {
1168 break;
1169 }
1170 }
1171
1172 None
1173}
1174
1175fn default_export_kind(node: &Node) -> SymbolKind {
1176 if node.kind().contains("class") {
1177 SymbolKind::Class
1178 } else {
1179 SymbolKind::Function
1180 }
1181}
1182
1183fn synthetic_default_symbol(path: &Path) -> String {
1184 let file_name = path
1185 .file_name()
1186 .and_then(|name| name.to_str())
1187 .unwrap_or("unknown");
1188 format!("<default:{file_name}>")
1189}
1190
1191fn first_line_signature(source: &str, node: &Node) -> String {
1192 let text = &source[node.byte_range()];
1193 let first_line = text.lines().next().unwrap_or(text);
1194 first_line
1195 .trim_end()
1196 .trim_end_matches('{')
1197 .trim_end()
1198 .to_string()
1199}
1200
1201fn node_text(node: tree_sitter::Node, source: &str) -> String {
1202 source[node.start_byte()..node.end_byte()].to_string()
1203}
1204
1205fn find_child_by_kind<'a>(
1207 node: tree_sitter::Node<'a>,
1208 kind: &str,
1209) -> Option<tree_sitter::Node<'a>> {
1210 let mut cursor = node.walk();
1211 if cursor.goto_first_child() {
1212 loop {
1213 if cursor.node().kind() == kind {
1214 return Some(cursor.node());
1215 }
1216 if !cursor.goto_next_sibling() {
1217 break;
1218 }
1219 }
1220 }
1221 None
1222}
1223
1224#[derive(Debug, Clone)]
1225struct CallSiteWithRange {
1226 full: String,
1227 short: String,
1228 line: u32,
1229 byte_start: usize,
1230 byte_end: usize,
1231}
1232
1233fn collect_calls_full_with_ranges(
1234 root: tree_sitter::Node,
1235 source: &str,
1236 byte_start: usize,
1237 byte_end: usize,
1238 lang: LangId,
1239) -> Vec<CallSiteWithRange> {
1240 let mut results = Vec::new();
1241 let call_kinds = call_node_kinds(lang);
1242 collect_calls_full_with_ranges_inner(
1243 root,
1244 source,
1245 byte_start,
1246 byte_end,
1247 &call_kinds,
1248 &mut results,
1249 );
1250 results
1251}
1252
1253fn collect_calls_full_with_ranges_inner(
1254 node: tree_sitter::Node,
1255 source: &str,
1256 byte_start: usize,
1257 byte_end: usize,
1258 call_kinds: &[&str],
1259 results: &mut Vec<CallSiteWithRange>,
1260) {
1261 let node_start = node.start_byte();
1262 let node_end = node.end_byte();
1263
1264 if node_end <= byte_start || node_start >= byte_end {
1265 return;
1266 }
1267
1268 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
1269 if let (Some(full), Some(short)) = (
1270 extract_full_callee(&node, source),
1271 extract_callee_name(&node, source),
1272 ) {
1273 results.push(CallSiteWithRange {
1274 full,
1275 short,
1276 line: node.start_position().row as u32 + 1,
1277 byte_start: node_start,
1278 byte_end: node_end,
1279 });
1280 }
1281 }
1282
1283 let mut cursor = node.walk();
1284 if cursor.goto_first_child() {
1285 loop {
1286 collect_calls_full_with_ranges_inner(
1287 cursor.node(),
1288 source,
1289 byte_start,
1290 byte_end,
1291 call_kinds,
1292 results,
1293 );
1294 if !cursor.goto_next_sibling() {
1295 break;
1296 }
1297 }
1298 }
1299}
1300
1301pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1309 if module_path.starts_with('.') {
1310 return resolve_relative_module_path(from_dir, module_path);
1311 }
1312
1313 if module_path.starts_with('/') {
1314 return None;
1315 }
1316
1317 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
1318 return Some(path);
1319 }
1320
1321 resolve_workspace_module_path(from_dir, module_path)
1322}
1323
1324fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1325 let base = from_dir.join(module_path);
1326 resolve_file_like_path(&base)
1327}
1328
1329fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
1330 let base = base.to_path_buf();
1331
1332 if base.is_file() {
1334 return Some(std::fs::canonicalize(&base).unwrap_or(base));
1335 }
1336
1337 for ext in JS_TS_EXTENSIONS {
1339 let with_ext = base.with_extension(ext);
1340 if with_ext.is_file() {
1341 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
1342 }
1343 }
1344
1345 if base.is_dir() {
1347 if let Some(index) = find_index_file(&base) {
1348 return Some(index);
1349 }
1350 }
1351
1352 None
1353}
1354
1355fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1356 let (package_name, subpath) = split_package_import(module_path)?;
1357 let package_root = find_package_root_for_import(from_dir, &package_name)?;
1358 resolve_package_entry(&package_root, &subpath)
1359}
1360
1361fn is_rust_source_file(path: &Path) -> bool {
1362 path.extension().and_then(|ext| ext.to_str()) == Some("rs")
1363}
1364
1365fn resolve_rust_cross_file_edge<F>(
1366 full_callee: &str,
1367 short_name: &str,
1368 caller_file: &Path,
1369 import_block: &ImportBlock,
1370 file_exports_symbol: &mut F,
1371) -> Option<ResolvedSymbol>
1372where
1373 F: FnMut(&Path, &str) -> bool,
1374{
1375 if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
1376 {
1377 return Some(target);
1378 }
1379
1380 resolve_rust_imported_call(
1381 caller_file,
1382 full_callee,
1383 short_name,
1384 import_block,
1385 file_exports_symbol,
1386 )
1387}
1388
1389fn resolve_rust_qualified_call<F>(
1390 caller_file: &Path,
1391 full_callee: &str,
1392 file_exports_symbol: &mut F,
1393) -> Option<ResolvedSymbol>
1394where
1395 F: FnMut(&Path, &str) -> bool,
1396{
1397 if !full_callee.contains("::") {
1398 return None;
1399 }
1400
1401 let segments = rust_path_segments(full_callee)?;
1402 resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
1403}
1404
1405fn resolve_rust_imported_call<F>(
1406 caller_file: &Path,
1407 full_callee: &str,
1408 short_name: &str,
1409 import_block: &ImportBlock,
1410 file_exports_symbol: &mut F,
1411) -> Option<ResolvedSymbol>
1412where
1413 F: FnMut(&Path, &str) -> bool,
1414{
1415 let call_segments = rust_path_segments(full_callee).unwrap_or_default();
1416 let bare_call_name = if call_segments.len() <= 1 {
1417 call_segments
1418 .first()
1419 .map(String::as_str)
1420 .unwrap_or(short_name)
1421 } else {
1422 short_name
1423 };
1424
1425 for imp in &import_block.imports {
1426 for entry in rust_use_entries(imp) {
1427 match &entry.kind {
1428 RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
1429 if entry.local_name != bare_call_name {
1430 continue;
1431 }
1432 let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
1433 else {
1434 continue;
1435 };
1436 if file_exports_symbol(&file, imported_name) {
1437 return Some(ResolvedSymbol {
1438 file,
1439 symbol: imported_name.clone(),
1440 });
1441 }
1442 }
1443 RustUseKind::Module if call_segments.len() >= 2 => {
1444 if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
1445 {
1446 continue;
1447 }
1448 let symbol = call_segments.last()?.clone();
1449 let mut module_path = entry.module_path.clone();
1450 for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
1451 module_path.push_str("::");
1452 module_path.push_str(segment);
1453 }
1454 let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
1455 continue;
1456 };
1457 if file_exports_symbol(&file, &symbol) {
1458 return Some(ResolvedSymbol { file, symbol });
1459 }
1460 }
1461 _ => {}
1462 }
1463 }
1464 }
1465
1466 None
1467}
1468
1469fn resolve_rust_call_segments<F>(
1470 caller_file: &Path,
1471 segments: &[String],
1472 file_exports_symbol: &mut F,
1473) -> Option<ResolvedSymbol>
1474where
1475 F: FnMut(&Path, &str) -> bool,
1476{
1477 if segments.len() < 2 {
1478 return None;
1479 }
1480
1481 let symbol = segments.last()?.clone();
1482 let module_path = segments[..segments.len() - 1].join("::");
1483 let file = resolve_rust_module_path(caller_file, &module_path)?;
1484 if file_exports_symbol(&file, &symbol) {
1485 Some(ResolvedSymbol { file, symbol })
1486 } else {
1487 None
1488 }
1489}
1490
1491fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
1492 let segments = rust_path_segments(module_path)?;
1493 let first = segments.first()?.as_str();
1494
1495 match first {
1496 "std" | "core" | "alloc" => None,
1497 "crate" => {
1498 let crate_root = find_rust_crate_root(caller_file)?;
1499 let crate_info = rust_crate_info(&crate_root)?;
1500 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1501 resolve_rust_module_segments(&base, &segments[1..])
1502 }
1503 "self" => {
1504 let crate_root = find_rust_crate_root(caller_file)?;
1505 let crate_info = rust_crate_info(&crate_root)?;
1506 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1507 if segments.len() == 1 {
1508 return Some(canonicalize_path(caller_file));
1509 }
1510 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1511 target_segments.extend(segments[1..].iter().cloned());
1512 resolve_rust_module_segments(&base, &target_segments)
1513 }
1514 "super" => {
1515 let crate_root = find_rust_crate_root(caller_file)?;
1516 let crate_info = rust_crate_info(&crate_root)?;
1517 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1518 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1519 target_segments.pop();
1520 target_segments.extend(segments[1..].iter().cloned());
1521 resolve_rust_module_segments(&base, &target_segments)
1522 }
1523 crate_name => {
1524 let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
1525 let workspace_crates = rust_workspace_crates(caller_dir)?;
1526 let crate_info = workspace_crates.get(crate_name)?;
1527 let base = rust_lib_module_base(crate_info)?;
1528 resolve_rust_module_segments(&base, &segments[1..])
1529 }
1530 }
1531}
1532
1533fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
1534 let Some(body) = rust_use_body(&imp.raw_text) else {
1535 return Vec::new();
1536 };
1537 let mut entries = Vec::new();
1538 expand_rust_use_tree(body, &mut entries);
1539 entries
1540}
1541
1542fn rust_use_body(raw: &str) -> Option<&str> {
1543 let use_pos = raw.find("use ")?;
1544 let body = raw[use_pos + 4..].trim();
1545 let body = body.strip_suffix(';').unwrap_or(body).trim();
1546 (!body.is_empty()).then_some(body)
1547}
1548
1549fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
1550 let path = path.trim();
1551 if path.is_empty() {
1552 return;
1553 }
1554
1555 if let Some((prefix, inner)) = split_rust_use_braces(path) {
1556 let prefix = prefix.trim().trim_end_matches("::").trim();
1557 for part in split_top_level_commas(inner) {
1558 let part = part.trim();
1559 if part.is_empty() {
1560 continue;
1561 }
1562 if part == "self" {
1563 if let Some(local_name) = rust_last_path_segment(prefix) {
1564 entries.push(RustUseEntry {
1565 module_path: prefix.to_string(),
1566 local_name,
1567 kind: RustUseKind::Module,
1568 });
1569 }
1570 continue;
1571 }
1572 let combined = if prefix.is_empty() {
1573 part.to_string()
1574 } else {
1575 format!("{prefix}::{part}")
1576 };
1577 expand_rust_use_tree(&combined, entries);
1578 }
1579 return;
1580 }
1581
1582 add_rust_use_leaf(path, entries);
1583}
1584
1585fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
1586 let mut depth = 0usize;
1587 let mut start = None;
1588 for (idx, ch) in path.char_indices() {
1589 match ch {
1590 '{' => {
1591 if depth == 0 {
1592 start = Some(idx);
1593 }
1594 depth += 1;
1595 }
1596 '}' => {
1597 depth = depth.checked_sub(1)?;
1598 if depth == 0 {
1599 let start = start?;
1600 if !path[idx + ch.len_utf8()..].trim().is_empty() {
1601 return None;
1602 }
1603 return Some((&path[..start], &path[start + 1..idx]));
1604 }
1605 }
1606 _ => {}
1607 }
1608 }
1609 None
1610}
1611
1612fn split_top_level_commas(value: &str) -> Vec<&str> {
1613 let mut parts = Vec::new();
1614 let mut depth = 0usize;
1615 let mut start = 0usize;
1616 for (idx, ch) in value.char_indices() {
1617 match ch {
1618 '{' => depth += 1,
1619 '}' => depth = depth.saturating_sub(1),
1620 ',' if depth == 0 => {
1621 parts.push(&value[start..idx]);
1622 start = idx + ch.len_utf8();
1623 }
1624 _ => {}
1625 }
1626 }
1627 parts.push(&value[start..]);
1628 parts
1629}
1630
1631fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
1632 let (path, alias) = split_rust_alias(path);
1633 let Some(segments) = rust_path_segments(path) else {
1634 return;
1635 };
1636 if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
1637 return;
1638 }
1639
1640 let imported_name = segments.last().cloned().unwrap_or_default();
1641 let local_name = alias.unwrap_or(&imported_name).to_string();
1642 if segments.len() >= 2 {
1643 entries.push(RustUseEntry {
1644 module_path: segments[..segments.len() - 1].join("::"),
1645 local_name: local_name.clone(),
1646 kind: RustUseKind::Item {
1647 imported_name: imported_name.clone(),
1648 },
1649 });
1650 }
1651
1652 entries.push(RustUseEntry {
1653 module_path: segments.join("::"),
1654 local_name,
1655 kind: RustUseKind::Module,
1656 });
1657}
1658
1659fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
1660 if let Some(idx) = path.rfind(" as ") {
1661 let original = path[..idx].trim();
1662 let alias = path[idx + 4..].trim();
1663 if !original.is_empty() && !alias.is_empty() {
1664 return (original, Some(alias));
1665 }
1666 }
1667 (path.trim(), None)
1668}
1669
1670fn rust_path_segments(path: &str) -> Option<Vec<String>> {
1671 let path = path.trim().trim_end_matches(';').trim();
1672 if path.is_empty() || path.contains('{') || path.contains('}') {
1673 return None;
1674 }
1675
1676 let mut segments = Vec::new();
1677 for raw_segment in path.split("::") {
1678 let segment = raw_segment.trim();
1679 if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
1680 return None;
1681 }
1682 let segment = segment.strip_prefix("r#").unwrap_or(segment);
1683 if segment
1684 .chars()
1685 .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
1686 {
1687 return None;
1688 }
1689 segments.push(segment.to_string());
1690 }
1691
1692 (!segments.is_empty()).then_some(segments)
1693}
1694
1695fn rust_last_path_segment(path: &str) -> Option<String> {
1696 rust_path_segments(path)?.last().cloned()
1697}
1698
1699fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
1700 let mut current = if from.is_file() {
1701 from.parent()
1702 } else {
1703 Some(from)
1704 };
1705 while let Some(dir) = current {
1706 if dir.join("Cargo.toml").is_file() {
1707 return Some(canonicalize_path(dir));
1708 }
1709 current = dir.parent();
1710 }
1711 None
1712}
1713
1714fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1715 let root = canonicalize_path(crate_root);
1716 if let Some(cached) = RUST_CRATE_INFO_CACHE
1717 .read()
1718 .ok()
1719 .and_then(|cache| cache.get(&root).cloned())
1720 {
1721 return cached;
1722 }
1723
1724 let resolved = read_rust_crate_info(&root);
1725 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
1726 cache.insert(root, resolved.clone());
1727 }
1728 resolved
1729}
1730
1731fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1732 let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
1733 let package = cargo.get("package")?;
1734 let package_name = package.get("name")?.as_str()?;
1735 let lib_name = cargo
1736 .get("lib")
1737 .and_then(|lib| lib.get("name"))
1738 .and_then(|name| name.as_str())
1739 .map(ToOwned::to_owned)
1740 .unwrap_or_else(|| package_name.replace('-', "_"));
1741
1742 let lib_root = cargo
1743 .get("lib")
1744 .and_then(|lib| lib.get("path"))
1745 .and_then(|path| path.as_str())
1746 .map(|path| crate_root.join(path))
1747 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
1748 let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
1749
1750 let main_root = crate_root.join("src/main.rs");
1751 let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
1752
1753 Some(RustCrateInfo {
1754 lib_name,
1755 lib_root,
1756 main_root,
1757 })
1758}
1759
1760fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
1761 let source = std::fs::read_to_string(path).ok()?;
1762 toml::from_str(&source).ok()
1763}
1764
1765fn rust_module_base_for_caller(
1766 crate_info: &RustCrateInfo,
1767 caller_file: &Path,
1768) -> Option<RustModuleBase> {
1769 let caller = canonicalize_path(caller_file);
1770 if crate_info.main_root.as_ref() == Some(&caller) {
1771 return rust_main_module_base(crate_info);
1772 }
1773 rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
1774}
1775
1776fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1777 let root_file = crate_info.lib_root.clone()?;
1778 let src_dir = root_file.parent()?.to_path_buf();
1779 Some(RustModuleBase { src_dir, root_file })
1780}
1781
1782fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1783 let root_file = crate_info.main_root.clone()?;
1784 let src_dir = root_file.parent()?.to_path_buf();
1785 Some(RustModuleBase { src_dir, root_file })
1786}
1787
1788fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
1789 if segments.is_empty() {
1790 return Some(base.root_file.clone());
1791 }
1792
1793 let module_base = segments
1794 .iter()
1795 .fold(base.src_dir.clone(), |path, segment| path.join(segment));
1796 let file_path = module_base.with_extension("rs");
1797 if file_path.is_file() {
1798 return Some(canonicalize_path(&file_path));
1799 }
1800
1801 let mod_path = module_base.join("mod.rs");
1802 if mod_path.is_file() {
1803 return Some(canonicalize_path(&mod_path));
1804 }
1805
1806 None
1807}
1808
1809fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
1810 let src_dir = canonicalize_path(src_dir);
1811 let file = canonicalize_path(file);
1812 let rel = file.strip_prefix(&src_dir).ok()?;
1813 let mut parts: Vec<String> = rel
1814 .components()
1815 .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
1816 .collect();
1817 if parts.is_empty() {
1818 return None;
1819 }
1820
1821 let last = parts.pop()?;
1822 if last == "lib.rs" || last == "main.rs" {
1823 return Some(Vec::new());
1824 }
1825 if last == "mod.rs" {
1826 return Some(parts);
1827 }
1828 let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
1829 parts.push(stem);
1830 Some(parts)
1831}
1832
1833fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
1834 let workspace_root =
1835 find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
1836 let workspace_root = canonicalize_path(&workspace_root);
1837
1838 if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
1839 .read()
1840 .ok()
1841 .and_then(|cache| cache.get(&workspace_root).cloned())
1842 {
1843 return Some(cached);
1844 }
1845
1846 let mut crates = HashMap::new();
1847 for member in rust_workspace_member_dirs(&workspace_root) {
1848 if let Some(info) = rust_crate_info(&member) {
1849 if info.lib_root.is_some() {
1850 crates.insert(info.lib_name.clone(), info);
1851 }
1852 }
1853 }
1854 if let Some(info) = rust_crate_info(&workspace_root) {
1855 if info.lib_root.is_some() {
1856 crates.insert(info.lib_name.clone(), info);
1857 }
1858 }
1859
1860 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
1861 cache.insert(workspace_root, crates.clone());
1862 }
1863 Some(crates)
1864}
1865
1866fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
1867 let mut current = Some(from_dir);
1868 while let Some(dir) = current {
1869 let cargo = dir.join("Cargo.toml");
1870 if rust_manifest_value(&cargo)
1871 .and_then(|value| value.get("workspace").cloned())
1872 .is_some()
1873 {
1874 return Some(canonicalize_path(dir));
1875 }
1876 current = dir.parent();
1877 }
1878 None
1879}
1880
1881fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
1882 let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
1883 return Vec::new();
1884 };
1885 let Some(members) = cargo
1886 .get("workspace")
1887 .and_then(|workspace| workspace.get("members"))
1888 .and_then(|members| members.as_array())
1889 else {
1890 return Vec::new();
1891 };
1892
1893 let mut dirs = Vec::new();
1894 for member in members.iter().filter_map(|member| member.as_str()) {
1895 dirs.extend(expand_rust_workspace_member(workspace_root, member));
1896 }
1897 dirs.sort();
1898 dirs.dedup();
1899 dirs
1900}
1901
1902fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
1903 let member = member.trim();
1904 if member.is_empty() {
1905 return Vec::new();
1906 }
1907
1908 if member.contains('*') || member.contains('?') || member.contains('[') {
1909 let pattern = workspace_root.join(member).to_string_lossy().to_string();
1910 return glob::glob(&pattern)
1911 .ok()
1912 .into_iter()
1913 .flatten()
1914 .filter_map(Result::ok)
1915 .filter(|path| path.join("Cargo.toml").is_file())
1916 .map(|path| canonicalize_path(&path))
1917 .collect();
1918 }
1919
1920 let path = workspace_root.join(member);
1921 if path.join("Cargo.toml").is_file() {
1922 vec![canonicalize_path(&path)]
1923 } else {
1924 Vec::new()
1925 }
1926}
1927
1928fn canonicalize_path(path: &Path) -> PathBuf {
1929 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1930}
1931
1932fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1933 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
1934 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
1935 let compiler_options = tsconfig.get("compilerOptions")?;
1936 let paths = compiler_options.get("paths")?.as_object()?;
1937 let base_url = compiler_options
1938 .get("baseUrl")
1939 .and_then(Value::as_str)
1940 .unwrap_or(".");
1941 let base_dir = tsconfig_dir.join(base_url);
1942
1943 for (alias, targets) in paths {
1944 let Some(capture) = ts_path_capture(alias, module_path) else {
1945 continue;
1946 };
1947 let Some(targets) = targets.as_array() else {
1948 continue;
1949 };
1950 for target in targets.iter().filter_map(Value::as_str) {
1951 let target = if target.contains('*') {
1952 target.replace('*', capture)
1953 } else {
1954 target.to_string()
1955 };
1956 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
1957 return Some(path);
1958 }
1959 }
1960 }
1961
1962 None
1963}
1964
1965fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
1966 let mut current = Some(from_dir);
1967 while let Some(dir) = current {
1968 if dir.join("tsconfig.json").is_file() {
1969 return Some(dir.to_path_buf());
1970 }
1971 current = dir.parent();
1972 }
1973 None
1974}
1975
1976fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
1977 if let Some(star_index) = alias.find('*') {
1978 let (prefix, suffix_with_star) = alias.split_at(star_index);
1979 let suffix = &suffix_with_star[1..];
1980 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
1981 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
1982 }
1983 return None;
1984 }
1985
1986 (alias == module_path).then_some("")
1987}
1988
1989fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
1990 let mut parts = module_path.split('/');
1991 let first = parts.next()?;
1992 if first.is_empty() {
1993 return None;
1994 }
1995
1996 if first.starts_with('@') {
1997 let second = parts.next()?;
1998 if second.is_empty() {
1999 return None;
2000 }
2001 let package_name = format!("{first}/{second}");
2002 let subpath = parts.collect::<Vec<_>>().join("/");
2003 let subpath = (!subpath.is_empty()).then_some(subpath);
2004 Some((package_name, subpath))
2005 } else {
2006 let package_name = first.to_string();
2007 let subpath = parts.collect::<Vec<_>>().join("/");
2008 let subpath = (!subpath.is_empty()).then_some(subpath);
2009 Some((package_name, subpath))
2010 }
2011}
2012
2013fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2014 let mut current = Some(from_dir);
2015 while let Some(dir) = current {
2016 if package_json_name(dir).as_deref() == Some(package_name) {
2017 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2018 }
2019 current = dir.parent();
2020 }
2021
2022 find_workspace_root(from_dir)
2023 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2024}
2025
2026fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2027 let mut current = Some(from_dir);
2028 while let Some(dir) = current {
2029 if is_workspace_root(dir) {
2030 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2031 }
2032 current = dir.parent();
2033 }
2034 None
2035}
2036
2037fn is_workspace_root(dir: &Path) -> bool {
2038 package_json_value(dir)
2039 .map(|value| !workspace_patterns(&value).is_empty())
2040 .unwrap_or(false)
2041 || !pnpm_workspace_patterns(dir).is_empty()
2042}
2043
2044pub(crate) fn clear_workspace_package_cache() {
2045 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2046 cache.clear();
2047 }
2048 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2049 cache.clear();
2050 }
2051 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2052 cache.clear();
2053 }
2054}
2055
2056fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2057 let workspace_root =
2058 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2059 let cache_key = (workspace_root.clone(), package_name.to_string());
2060
2061 if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2062 if let Some(cached) = cache.get(&cache_key) {
2063 return cached.clone();
2064 }
2065 }
2066
2067 let resolved = workspace_member_dirs(&workspace_root)
2068 .into_iter()
2069 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2070 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2071
2072 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2073 cache.insert(cache_key, resolved.clone());
2074 }
2075
2076 resolved
2077}
2078
2079fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2080 let mut patterns = package_json_value(workspace_root)
2081 .map(|package_json| workspace_patterns(&package_json))
2082 .unwrap_or_default();
2083 patterns.extend(pnpm_workspace_patterns(workspace_root));
2084
2085 expand_workspace_patterns(workspace_root, &patterns)
2086}
2087
2088fn workspace_patterns(package_json: &Value) -> Vec<String> {
2089 match package_json.get("workspaces") {
2090 Some(Value::Array(items)) => items
2091 .iter()
2092 .filter_map(non_empty_workspace_pattern)
2093 .collect(),
2094 Some(Value::Object(map)) => map
2095 .get("packages")
2096 .and_then(Value::as_array)
2097 .map(|items| {
2098 items
2099 .iter()
2100 .filter_map(non_empty_workspace_pattern)
2101 .collect()
2102 })
2103 .unwrap_or_default(),
2104 _ => Vec::new(),
2105 }
2106}
2107
2108fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2109 let pattern = value.as_str()?.trim();
2110 (!pattern.is_empty()).then(|| pattern.to_string())
2111}
2112
2113fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2114 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2115 return Vec::new();
2116 };
2117
2118 let mut patterns = Vec::new();
2119 let mut in_packages = false;
2120 for line in source.lines() {
2121 let without_comment = line.split('#').next().unwrap_or("").trim_end();
2122 let trimmed = without_comment.trim();
2123 if trimmed.is_empty() {
2124 continue;
2125 }
2126 if trimmed == "packages:" {
2127 in_packages = true;
2128 continue;
2129 }
2130 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2131 in_packages = false;
2132 }
2133 if in_packages {
2134 if let Some(pattern) = trimmed.strip_prefix('-') {
2135 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2136 if !pattern.is_empty() {
2137 patterns.push(pattern.to_string());
2138 }
2139 }
2140 }
2141 }
2142 patterns
2143}
2144
2145fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2146 let positive_patterns: Vec<&str> = patterns
2147 .iter()
2148 .map(|pattern| pattern.trim())
2149 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2150 .collect();
2151 if positive_patterns.is_empty() {
2152 return Vec::new();
2153 }
2154
2155 let positives = build_glob_set(&positive_patterns);
2156 let negative_patterns: Vec<&str> = patterns
2157 .iter()
2158 .map(|pattern| pattern.trim())
2159 .filter_map(|pattern| pattern.strip_prefix('!'))
2160 .map(str::trim)
2161 .filter(|pattern| !pattern.is_empty())
2162 .collect();
2163 let negatives = build_glob_set(&negative_patterns);
2164
2165 let mut members = Vec::new();
2166 collect_workspace_member_dirs(
2167 workspace_root,
2168 workspace_root,
2169 &positives,
2170 &negatives,
2171 &mut members,
2172 );
2173 members
2174}
2175
2176fn build_glob_set(patterns: &[&str]) -> GlobSet {
2177 let mut builder = GlobSetBuilder::new();
2178 for pattern in patterns {
2179 if let Ok(glob) = Glob::new(pattern) {
2180 builder.add(glob);
2181 }
2182 }
2183 builder
2184 .build()
2185 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
2186}
2187
2188fn collect_workspace_member_dirs(
2189 workspace_root: &Path,
2190 dir: &Path,
2191 positives: &GlobSet,
2192 negatives: &GlobSet,
2193 members: &mut Vec<PathBuf>,
2194) {
2195 let Ok(entries) = std::fs::read_dir(dir) else {
2196 return;
2197 };
2198
2199 for entry in entries.filter_map(Result::ok) {
2200 let path = entry.path();
2201 let Ok(file_type) = entry.file_type() else {
2202 continue;
2203 };
2204 if !file_type.is_dir() {
2205 continue;
2206 }
2207 let name = entry.file_name();
2208 let name = name.to_string_lossy();
2209 if matches!(
2210 name.as_ref(),
2211 "node_modules" | ".git" | "target" | "dist" | "build"
2212 ) {
2213 continue;
2214 }
2215
2216 if path.join("package.json").is_file() {
2217 if let Ok(rel) = path.strip_prefix(workspace_root) {
2218 let rel = rel.to_string_lossy().replace('\\', "/");
2219 if positives.is_match(&rel) && !negatives.is_match(&rel) {
2220 members.push(path.clone());
2221 }
2222 }
2223 }
2224
2225 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
2226 }
2227}
2228
2229fn package_json_value(dir: &Path) -> Option<Value> {
2230 package_json_like_value(&dir.join("package.json"))
2231}
2232
2233fn package_json_like_value(path: &Path) -> Option<Value> {
2234 let json = std::fs::read_to_string(path).ok()?;
2235 serde_json::from_str(&json).ok()
2236}
2237
2238fn package_json_name(dir: &Path) -> Option<String> {
2239 package_json_value(dir)?
2240 .get("name")?
2241 .as_str()
2242 .map(ToOwned::to_owned)
2243}
2244
2245fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
2246 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
2247
2248 if let Some(exports) = package_json.get("exports") {
2249 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2250 if let Some(path) = resolve_package_target(package_root, &target) {
2251 return Some(path);
2252 }
2253 }
2254 }
2255
2256 if subpath.is_none() {
2257 for field in ["module", "main"] {
2258 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2259 if let Some(path) = resolve_package_target(package_root, target) {
2260 return Some(path);
2261 }
2262 }
2263 }
2264 }
2265
2266 resolve_package_fallback(package_root, subpath.as_deref())
2267}
2268
2269fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2270 let key = subpath
2271 .map(|value| format!("./{value}"))
2272 .unwrap_or_else(|| ".".to_string());
2273
2274 match exports {
2275 Value::String(target) if key == "." => Some(target.clone()),
2276 Value::Object(map) => {
2277 if let Some(target) = map.get(&key).and_then(export_condition_target) {
2278 return Some(target);
2279 }
2280
2281 if let Some(target) = wildcard_export_target(map, &key) {
2282 return Some(target);
2283 }
2284
2285 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2286 return export_condition_target(exports);
2287 }
2288
2289 None
2290 }
2291 _ => None,
2292 }
2293}
2294
2295fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2296 for (pattern, target) in map {
2297 let Some(star_index) = pattern.find('*') else {
2298 continue;
2299 };
2300 let (prefix, suffix_with_star) = pattern.split_at(star_index);
2301 let suffix = &suffix_with_star[1..];
2302 if !key.starts_with(prefix) || !key.ends_with(suffix) {
2303 continue;
2304 }
2305 let matched = &key[prefix.len()..key.len() - suffix.len()];
2306 if let Some(target_pattern) = export_condition_target(target) {
2307 return Some(target_pattern.replace('*', matched));
2308 }
2309 }
2310 None
2311}
2312
2313fn export_condition_target(value: &Value) -> Option<String> {
2314 match value {
2315 Value::String(target) => Some(target.clone()),
2316 Value::Object(map) => ["source", "import", "module", "default", "types"]
2317 .into_iter()
2318 .find_map(|field| map.get(field).and_then(export_condition_target)),
2319 _ => None,
2320 }
2321}
2322
2323fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2324 let target = target.strip_prefix("./").unwrap_or(target);
2325 if let Some(src_relative) = target.strip_prefix("dist/") {
2328 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2329 return Some(path);
2330 }
2331 }
2332
2333 resolve_file_like_path(&package_root.join(target))
2334}
2335
2336fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2337 match subpath {
2338 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2339 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2340 None => resolve_file_like_path(&package_root.join("src").join("index"))
2341 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2342 }
2343}
2344
2345pub(crate) fn resolve_reexported_symbol_target<F, D>(
2346 file: &Path,
2347 symbol_name: &str,
2348 file_exports_symbol: &mut F,
2349 file_default_export_symbol: &mut D,
2350) -> Option<(PathBuf, String)>
2351where
2352 F: FnMut(&Path, &str) -> bool,
2353 D: FnMut(&Path) -> Option<String>,
2354{
2355 resolve_reexported_symbol(
2356 file,
2357 symbol_name,
2358 file_exports_symbol,
2359 file_default_export_symbol,
2360 )
2361 .map(|target| (target.file, target.symbol))
2362}
2363
2364fn resolve_reexported_symbol<F, D>(
2365 file: &Path,
2366 symbol_name: &str,
2367 file_exports_symbol: &mut F,
2368 file_default_export_symbol: &mut D,
2369) -> Option<ResolvedSymbol>
2370where
2371 F: FnMut(&Path, &str) -> bool,
2372 D: FnMut(&Path) -> Option<String>,
2373{
2374 let mut visited = HashSet::new();
2375 resolve_reexported_symbol_inner(
2376 file,
2377 symbol_name,
2378 file_exports_symbol,
2379 file_default_export_symbol,
2380 &mut visited,
2381 )
2382}
2383
2384fn resolve_reexported_symbol_inner<F, D>(
2385 file: &Path,
2386 symbol_name: &str,
2387 file_exports_symbol: &mut F,
2388 file_default_export_symbol: &mut D,
2389 visited: &mut HashSet<(PathBuf, String)>,
2390) -> Option<ResolvedSymbol>
2391where
2392 F: FnMut(&Path, &str) -> bool,
2393 D: FnMut(&Path) -> Option<String>,
2394{
2395 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2396 if !visited.insert((canon.clone(), symbol_name.to_string())) {
2397 return None;
2398 }
2399
2400 let source = std::fs::read_to_string(&canon).ok()?;
2401 let lang = detect_language(&canon)?;
2402 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2403 if symbol_name == "default" {
2404 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
2405 file: canon,
2406 symbol,
2407 });
2408 }
2409 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
2410 file: canon,
2411 symbol: symbol_name.to_string(),
2412 });
2413 }
2414
2415 let grammar = grammar_for(lang);
2416 let mut parser = Parser::new();
2417 parser.set_language(&grammar).ok()?;
2418 let tree = parser.parse(&source, None)?;
2419 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2420
2421 let mut cursor = tree.root_node().walk();
2422 if !cursor.goto_first_child() {
2423 return None;
2424 }
2425
2426 loop {
2427 let node = cursor.node();
2428 if node.kind() == "export_statement" {
2429 if let Some(target) = resolve_reexport_statement(
2430 &source,
2431 node,
2432 from_dir,
2433 symbol_name,
2434 file_exports_symbol,
2435 file_default_export_symbol,
2436 visited,
2437 ) {
2438 return Some(target);
2439 }
2440 }
2441
2442 if !cursor.goto_next_sibling() {
2443 break;
2444 }
2445 }
2446
2447 if symbol_name == "default" {
2448 if let Some(symbol) = file_default_export_symbol(&canon) {
2449 return Some(ResolvedSymbol {
2450 file: canon,
2451 symbol,
2452 });
2453 }
2454 }
2455
2456 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
2457 return Some(ResolvedSymbol {
2458 file: canon,
2459 symbol,
2460 });
2461 }
2462
2463 if file_exports_symbol(&canon, symbol_name) {
2464 let symbol = symbol_name.to_string();
2465 return Some(ResolvedSymbol {
2466 file: canon,
2467 symbol,
2468 });
2469 }
2470
2471 None
2472}
2473
2474fn resolve_reexport_statement<F, D>(
2475 source: &str,
2476 node: tree_sitter::Node,
2477 from_dir: &Path,
2478 symbol_name: &str,
2479 file_exports_symbol: &mut F,
2480 file_default_export_symbol: &mut D,
2481 visited: &mut HashSet<(PathBuf, String)>,
2482) -> Option<ResolvedSymbol>
2483where
2484 F: FnMut(&Path, &str) -> bool,
2485 D: FnMut(&Path) -> Option<String>,
2486{
2487 let source_node = node
2488 .child_by_field_name("source")
2489 .or_else(|| find_child_by_kind(node, "string"))?;
2490 let module_path = string_literal_content(source, source_node)?;
2491 let target_file = resolve_module_path(from_dir, &module_path)?;
2492 let raw_export = node_text(node, source);
2493
2494 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2495 return resolve_reexported_symbol_inner(
2496 &target_file,
2497 &source_symbol,
2498 file_exports_symbol,
2499 file_default_export_symbol,
2500 visited,
2501 )
2502 .or(Some(ResolvedSymbol {
2503 file: target_file,
2504 symbol: source_symbol,
2505 }));
2506 }
2507
2508 if raw_export.contains('*') {
2509 return resolve_reexported_symbol_inner(
2510 &target_file,
2511 symbol_name,
2512 file_exports_symbol,
2513 file_default_export_symbol,
2514 visited,
2515 );
2516 }
2517
2518 None
2519}
2520
2521fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
2522 let lang = detect_language(file)?;
2523 let grammar = grammar_for(lang);
2524 let mut parser = Parser::new();
2525 parser.set_language(&grammar).ok()?;
2526 let tree = parser.parse(source, None)?;
2527
2528 let mut cursor = tree.root_node().walk();
2529 if !cursor.goto_first_child() {
2530 return None;
2531 }
2532
2533 loop {
2534 let node = cursor.node();
2535 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
2536 let raw_export = node_text(node, source);
2537 if let Some(source_symbol) =
2538 reexport_clause_source_symbol(&raw_export, requested_export)
2539 {
2540 return Some(source_symbol);
2541 }
2542 }
2543
2544 if !cursor.goto_next_sibling() {
2545 break;
2546 }
2547 }
2548
2549 None
2550}
2551
2552fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
2553 let start = raw_export.find('{')? + 1;
2554 let end = raw_export[start..].find('}')? + start;
2555 for specifier in raw_export[start..end].split(',') {
2556 let specifier = specifier.trim();
2557 if specifier.is_empty() {
2558 continue;
2559 }
2560 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
2561 if let Some((imported, exported)) = specifier.split_once(" as ") {
2562 if exported.trim() == requested_export {
2563 return Some(imported.trim().to_string());
2564 }
2565 } else if specifier == requested_export {
2566 return Some(requested_export.to_string());
2567 }
2568 }
2569 None
2570}
2571
2572fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2573 let raw = source[node.byte_range()].trim();
2574 let quote = raw.chars().next()?;
2575 if quote != '\'' && quote != '"' {
2576 return None;
2577 }
2578 raw.strip_prefix(quote)
2579 .and_then(|value| value.strip_suffix(quote))
2580 .map(ToOwned::to_owned)
2581}
2582
2583fn find_index_file(dir: &Path) -> Option<PathBuf> {
2585 for name in JS_TS_INDEX_FILES {
2586 let p = dir.join(name);
2587 if p.is_file() {
2588 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2589 }
2590 }
2591 None
2592}
2593
2594fn resolve_aliased_import(
2597 local_name: &str,
2598 import_block: &ImportBlock,
2599 caller_dir: &Path,
2600) -> Option<(String, PathBuf)> {
2601 for imp in &import_block.imports {
2602 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2605 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2606 return Some((original, resolved_path));
2607 }
2608 }
2609 }
2610 None
2611}
2612
2613fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2617 let search = format!(" as {}", local_name);
2620 if let Some(pos) = raw_import.find(&search) {
2621 let before = &raw_import[..pos];
2623 let original = before
2625 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2626 .find(|s| !s.is_empty())?;
2627 return Some(original.to_string());
2628 }
2629 None
2630}
2631
2632pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2640 use ignore::WalkBuilder;
2641
2642 let walker = WalkBuilder::new(root)
2643 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .add_custom_ignore_filename(".aftignore") .filter_entry(|entry| {
2649 let name = entry.file_name().to_string_lossy();
2650 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2652 return !matches!(
2653 name.as_ref(),
2654 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2655 | ".tox" | "dist" | "build"
2656 );
2657 }
2658 true
2659 })
2660 .build();
2661
2662 walker
2663 .filter_map(|entry| entry.ok())
2664 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2665 .filter(|entry| detect_language(entry.path()).is_some())
2666 .map(|entry| entry.into_path())
2667}
2668
2669#[cfg(test)]
2674mod tests {
2675 use super::*;
2676 use std::fs;
2677 use tempfile::TempDir;
2678
2679 #[test]
2680 fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
2681 let mut symbol_metadata = HashMap::new();
2686 symbol_metadata.insert(
2687 "BackupStore::total_disk_bytes".to_string(),
2688 SymbolMeta {
2689 kind: SymbolKind::Method,
2690 exported: true,
2691 signature: None,
2692 line: 703,
2693 range: Range {
2694 start_line: 702,
2695 start_col: 0,
2696 end_line: 705,
2697 end_col: 0,
2698 },
2699 entry_point_attribute: None,
2700 },
2701 );
2702 let file_data = FileCallData {
2703 calls_by_symbol: HashMap::new(),
2704 exported_symbols: vec!["total_disk_bytes".to_string()],
2705 symbol_metadata,
2706 default_export_symbol: None,
2707 import_block: ImportBlock::empty(),
2708 lang: LangId::Rust,
2709 };
2710
2711 let meta = file_data
2712 .symbol_metadata_for("total_disk_bytes")
2713 .expect("scoped method recovered by bare name");
2714 assert_eq!(meta.kind, SymbolKind::Method);
2715 assert_eq!(
2716 meta.line, 703,
2717 "real declaration line, not the line-1 fallback"
2718 );
2719
2720 assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
2722 }
2723
2724 fn setup_ts_project() -> TempDir {
2726 let dir = TempDir::new().unwrap();
2727
2728 fs::write(
2730 dir.path().join("main.ts"),
2731 r#"import { helper, compute } from './utils';
2732import * as math from './math';
2733
2734export function main() {
2735 const a = helper(1);
2736 const b = compute(a, 2);
2737 const c = math.add(a, b);
2738 return c;
2739}
2740"#,
2741 )
2742 .unwrap();
2743
2744 fs::write(
2746 dir.path().join("utils.ts"),
2747 r#"import { double } from './helpers';
2748
2749export function helper(x: number): number {
2750 return double(x);
2751}
2752
2753export function compute(a: number, b: number): number {
2754 return a + b;
2755}
2756"#,
2757 )
2758 .unwrap();
2759
2760 fs::write(
2762 dir.path().join("helpers.ts"),
2763 r#"export function double(x: number): number {
2764 return x * 2;
2765}
2766
2767export function triple(x: number): number {
2768 return x * 3;
2769}
2770"#,
2771 )
2772 .unwrap();
2773
2774 fs::write(
2776 dir.path().join("math.ts"),
2777 r#"export function add(a: number, b: number): number {
2778 return a + b;
2779}
2780
2781export function subtract(a: number, b: number): number {
2782 return a - b;
2783}
2784"#,
2785 )
2786 .unwrap();
2787
2788 dir
2789 }
2790
2791 fn setup_alias_project() -> TempDir {
2793 let dir = TempDir::new().unwrap();
2794
2795 fs::write(
2796 dir.path().join("main.ts"),
2797 r#"import { helper as h } from './utils';
2798
2799export function main() {
2800 return h(42);
2801}
2802"#,
2803 )
2804 .unwrap();
2805
2806 fs::write(
2807 dir.path().join("utils.ts"),
2808 r#"export function helper(x: number): number {
2809 return x + 1;
2810}
2811"#,
2812 )
2813 .unwrap();
2814
2815 dir
2816 }
2817
2818 #[test]
2821 fn callgraph_single_file_call_extraction() {
2822 let dir = setup_ts_project();
2823 let mut graph = CallGraph::new(dir.path().to_path_buf());
2824
2825 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2826 let main_calls = &file_data.calls_by_symbol["main"];
2827
2828 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2829 assert!(
2830 callee_names.contains(&"helper"),
2831 "main should call helper, got: {:?}",
2832 callee_names
2833 );
2834 assert!(
2835 callee_names.contains(&"compute"),
2836 "main should call compute, got: {:?}",
2837 callee_names
2838 );
2839 assert!(
2840 callee_names.contains(&"add"),
2841 "main should call math.add (short name: add), got: {:?}",
2842 callee_names
2843 );
2844 }
2845
2846 #[test]
2847 fn callgraph_file_data_has_exports() {
2848 let dir = setup_ts_project();
2849 let mut graph = CallGraph::new(dir.path().to_path_buf());
2850
2851 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2852 assert!(
2853 file_data.exported_symbols.contains(&"helper".to_string()),
2854 "utils.ts should export helper, got: {:?}",
2855 file_data.exported_symbols
2856 );
2857 assert!(
2858 file_data.exported_symbols.contains(&"compute".to_string()),
2859 "utils.ts should export compute, got: {:?}",
2860 file_data.exported_symbols
2861 );
2862 }
2863
2864 #[test]
2867 fn callgraph_resolve_direct_import() {
2868 let dir = setup_ts_project();
2869 let mut graph = CallGraph::new(dir.path().to_path_buf());
2870
2871 let main_path = dir.path().join("main.ts");
2872 let file_data = graph.build_file(&main_path).unwrap();
2873 let import_block = file_data.import_block.clone();
2874
2875 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2876 match edge {
2877 EdgeResolution::Resolved { file, symbol } => {
2878 assert!(
2879 file.ends_with("utils.ts"),
2880 "helper should resolve to utils.ts, got: {:?}",
2881 file
2882 );
2883 assert_eq!(symbol, "helper");
2884 }
2885 EdgeResolution::Unresolved { callee_name } => {
2886 panic!("Expected resolved, got unresolved: {}", callee_name);
2887 }
2888 }
2889 }
2890
2891 #[test]
2892 fn callgraph_resolve_namespace_import() {
2893 let dir = setup_ts_project();
2894 let mut graph = CallGraph::new(dir.path().to_path_buf());
2895
2896 let main_path = dir.path().join("main.ts");
2897 let file_data = graph.build_file(&main_path).unwrap();
2898 let import_block = file_data.import_block.clone();
2899
2900 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2901 match edge {
2902 EdgeResolution::Resolved { file, symbol } => {
2903 assert!(
2904 file.ends_with("math.ts"),
2905 "math.add should resolve to math.ts, got: {:?}",
2906 file
2907 );
2908 assert_eq!(symbol, "add");
2909 }
2910 EdgeResolution::Unresolved { callee_name } => {
2911 panic!("Expected resolved, got unresolved: {}", callee_name);
2912 }
2913 }
2914 }
2915
2916 #[test]
2917 fn callgraph_resolve_aliased_import() {
2918 let dir = setup_alias_project();
2919 let mut graph = CallGraph::new(dir.path().to_path_buf());
2920
2921 let main_path = dir.path().join("main.ts");
2922 let file_data = graph.build_file(&main_path).unwrap();
2923 let import_block = file_data.import_block.clone();
2924
2925 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2926 match edge {
2927 EdgeResolution::Resolved { file, symbol } => {
2928 assert!(
2929 file.ends_with("utils.ts"),
2930 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2931 file
2932 );
2933 assert_eq!(symbol, "helper");
2934 }
2935 EdgeResolution::Unresolved { callee_name } => {
2936 panic!("Expected resolved, got unresolved: {}", callee_name);
2937 }
2938 }
2939 }
2940
2941 #[test]
2942 fn callgraph_unresolved_edge_marked() {
2943 let dir = setup_ts_project();
2944 let mut graph = CallGraph::new(dir.path().to_path_buf());
2945
2946 let main_path = dir.path().join("main.ts");
2947 let file_data = graph.build_file(&main_path).unwrap();
2948 let import_block = file_data.import_block.clone();
2949
2950 let edge =
2951 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2952 assert_eq!(
2953 edge,
2954 EdgeResolution::Unresolved {
2955 callee_name: "unknownFunc".to_string()
2956 },
2957 "Unknown callee should be unresolved"
2958 );
2959 }
2960
2961 #[test]
2964 fn callgraph_walker_excludes_gitignored() {
2965 let dir = TempDir::new().unwrap();
2966
2967 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2969
2970 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2972 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2973 fs::write(
2974 dir.path().join("ignored_dir").join("secret.ts"),
2975 "export function secret() {}",
2976 )
2977 .unwrap();
2978
2979 fs::create_dir(dir.path().join("node_modules")).unwrap();
2981 fs::write(
2982 dir.path().join("node_modules").join("dep.ts"),
2983 "export function dep() {}",
2984 )
2985 .unwrap();
2986
2987 std::process::Command::new("git")
2989 .args(["init"])
2990 .current_dir(dir.path())
2991 .output()
2992 .unwrap();
2993
2994 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2995 let file_names: Vec<String> = files
2996 .iter()
2997 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2998 .collect();
2999
3000 assert!(
3001 file_names.contains(&"main.ts".to_string()),
3002 "Should include main.ts, got: {:?}",
3003 file_names
3004 );
3005 assert!(
3006 !file_names.contains(&"secret.ts".to_string()),
3007 "Should exclude gitignored secret.ts, got: {:?}",
3008 file_names
3009 );
3010 assert!(
3011 !file_names.contains(&"dep.ts".to_string()),
3012 "Should exclude node_modules, got: {:?}",
3013 file_names
3014 );
3015 }
3016
3017 #[test]
3018 fn callgraph_walker_excludes_aftignored() {
3019 let dir = TempDir::new().unwrap();
3020
3021 fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
3023 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3024 fs::create_dir(dir.path().join("vendored")).unwrap();
3025 fs::write(
3026 dir.path().join("vendored").join("sub.ts"),
3027 "export function sub() {}",
3028 )
3029 .unwrap();
3030
3031 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3032 let file_names: Vec<String> = files
3033 .iter()
3034 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3035 .collect();
3036
3037 assert!(
3038 file_names.contains(&"main.ts".to_string()),
3039 "Should include main.ts, got: {:?}",
3040 file_names
3041 );
3042 assert!(
3043 !file_names.contains(&"sub.ts".to_string()),
3044 "Should exclude .aftignored sub.ts, got: {:?}",
3045 file_names
3046 );
3047 }
3048
3049 #[test]
3050 fn callgraph_walker_only_source_files() {
3051 let dir = TempDir::new().unwrap();
3052
3053 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3054 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
3055 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
3056 fs::write(
3057 dir.path().join("runtime.mjs"),
3058 "export function runtime() {}",
3059 )
3060 .unwrap();
3061 fs::write(
3062 dir.path().join("legacy.cjs"),
3063 "exports.legacy = function() {};",
3064 )
3065 .unwrap();
3066 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
3067 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3068 fs::write(dir.path().join("data.json"), "{}").unwrap();
3069
3070 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3071 let file_names: Vec<String> = files
3072 .iter()
3073 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3074 .collect();
3075
3076 assert!(file_names.contains(&"main.ts".to_string()));
3077 for modern_ext_file in [
3078 "module.mts",
3079 "common.cts",
3080 "runtime.mjs",
3081 "legacy.cjs",
3082 "types.pyi",
3083 ] {
3084 assert!(
3085 file_names.contains(&modern_ext_file.to_string()),
3086 "walker should include {modern_ext_file}, got: {:?}",
3087 file_names
3088 );
3089 }
3090 assert!(
3091 file_names.contains(&"readme.md".to_string()),
3092 "Markdown is now a supported source language"
3093 );
3094 assert!(
3095 file_names.contains(&"data.json".to_string()),
3096 "JSON is now a supported source language"
3097 );
3098 }
3099
3100 #[test]
3103 fn callgraph_find_alias_original_simple() {
3104 let raw = "import { foo as bar } from './utils';";
3105 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3106 }
3107
3108 #[test]
3109 fn callgraph_find_alias_original_multiple() {
3110 let raw = "import { foo as bar, baz as qux } from './utils';";
3111 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3112 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3113 }
3114
3115 #[test]
3116 fn callgraph_find_alias_no_match() {
3117 let raw = "import { foo } from './utils';";
3118 assert_eq!(find_alias_original(raw, "foo"), None);
3119 }
3120
3121 #[test]
3124 fn is_entry_point_exported_function() {
3125 assert!(is_entry_point(
3126 "handleRequest",
3127 &SymbolKind::Function,
3128 true,
3129 LangId::TypeScript
3130 ));
3131 }
3132
3133 #[test]
3134 fn is_entry_point_exported_method_is_not_entry() {
3135 assert!(!is_entry_point(
3137 "handleRequest",
3138 &SymbolKind::Method,
3139 true,
3140 LangId::TypeScript
3141 ));
3142 }
3143
3144 #[test]
3145 fn is_entry_point_main_init_patterns() {
3146 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3147 assert!(
3148 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3149 "{} should be an entry point",
3150 name
3151 );
3152 }
3153 }
3154
3155 #[test]
3156 fn is_entry_point_test_patterns_ts() {
3157 assert!(is_entry_point(
3158 "describe",
3159 &SymbolKind::Function,
3160 false,
3161 LangId::TypeScript
3162 ));
3163 assert!(is_entry_point(
3164 "it",
3165 &SymbolKind::Function,
3166 false,
3167 LangId::TypeScript
3168 ));
3169 assert!(is_entry_point(
3170 "test",
3171 &SymbolKind::Function,
3172 false,
3173 LangId::TypeScript
3174 ));
3175 assert!(is_entry_point(
3176 "testValidation",
3177 &SymbolKind::Function,
3178 false,
3179 LangId::TypeScript
3180 ));
3181 assert!(is_entry_point(
3182 "specHelper",
3183 &SymbolKind::Function,
3184 false,
3185 LangId::TypeScript
3186 ));
3187 }
3188
3189 #[test]
3190 fn is_entry_point_test_patterns_python() {
3191 assert!(is_entry_point(
3192 "test_login",
3193 &SymbolKind::Function,
3194 false,
3195 LangId::Python
3196 ));
3197 assert!(is_entry_point(
3198 "setUp",
3199 &SymbolKind::Function,
3200 false,
3201 LangId::Python
3202 ));
3203 assert!(is_entry_point(
3204 "tearDown",
3205 &SymbolKind::Function,
3206 false,
3207 LangId::Python
3208 ));
3209 assert!(!is_entry_point(
3211 "testSomething",
3212 &SymbolKind::Function,
3213 false,
3214 LangId::Python
3215 ));
3216 }
3217
3218 #[test]
3219 fn is_entry_point_test_patterns_rust() {
3220 assert!(is_entry_point(
3221 "test_parse",
3222 &SymbolKind::Function,
3223 false,
3224 LangId::Rust
3225 ));
3226 assert!(!is_entry_point(
3227 "TestSomething",
3228 &SymbolKind::Function,
3229 false,
3230 LangId::Rust
3231 ));
3232 }
3233
3234 #[test]
3235 fn is_entry_point_test_patterns_go() {
3236 assert!(is_entry_point(
3237 "TestParsing",
3238 &SymbolKind::Function,
3239 false,
3240 LangId::Go
3241 ));
3242 assert!(!is_entry_point(
3244 "testParsing",
3245 &SymbolKind::Function,
3246 false,
3247 LangId::Go
3248 ));
3249 }
3250
3251 #[test]
3252 fn is_entry_point_non_exported_non_main_is_not_entry() {
3253 assert!(!is_entry_point(
3254 "helperUtil",
3255 &SymbolKind::Function,
3256 false,
3257 LangId::TypeScript
3258 ));
3259 }
3260
3261 #[test]
3264 fn callgraph_symbol_metadata_populated() {
3265 let dir = setup_ts_project();
3266 let mut graph = CallGraph::new(dir.path().to_path_buf());
3267
3268 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3269 assert!(
3270 file_data.symbol_metadata.contains_key("helper"),
3271 "symbol_metadata should contain helper"
3272 );
3273 let meta = &file_data.symbol_metadata["helper"];
3274 assert_eq!(meta.kind, SymbolKind::Function);
3275 assert!(meta.exported, "helper should be exported");
3276 }
3277
3278 #[test]
3279 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
3280 let dir = TempDir::new().unwrap();
3281 fs::write(
3282 dir.path().join("main.ts"),
3283 r#"import * as lib from './index';
3284
3285export function main() {
3286 lib.helper();
3287 lib.hidden();
3288}
3289"#,
3290 )
3291 .unwrap();
3292 fs::write(
3293 dir.path().join("index.ts"),
3294 "export { helper } from './utils';\n",
3295 )
3296 .unwrap();
3297 fs::write(
3298 dir.path().join("utils.ts"),
3299 r#"export function helper() {}
3300function hidden() {}
3301"#,
3302 )
3303 .unwrap();
3304
3305 let mut graph = CallGraph::new(dir.path().to_path_buf());
3306 let main_path = dir.path().join("main.ts");
3307 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
3308
3309 let helper =
3310 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
3311 match helper {
3312 EdgeResolution::Resolved { file, symbol } => {
3313 assert!(
3314 file.ends_with("utils.ts"),
3315 "helper should resolve through barrel: {file:?}"
3316 );
3317 assert_eq!(symbol, "helper");
3318 }
3319 other => panic!("expected helper to resolve through barrel, got {other:?}"),
3320 }
3321
3322 let hidden =
3323 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
3324 assert_eq!(
3325 hidden,
3326 EdgeResolution::Unresolved {
3327 callee_name: "hidden".to_string()
3328 }
3329 );
3330 }
3331
3332 #[test]
3333 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
3334 let dir = TempDir::new().unwrap();
3335 fs::write(
3336 dir.path().join("package.json"),
3337 r#"{"workspaces":["packages/*"]}"#,
3338 )
3339 .unwrap();
3340 let package_dir = dir.path().join("packages/lib");
3341 fs::create_dir_all(package_dir.join("src")).unwrap();
3342 fs::create_dir_all(package_dir.join("dist")).unwrap();
3343 fs::write(
3344 package_dir.join("package.json"),
3345 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
3346 )
3347 .unwrap();
3348 fs::write(
3349 package_dir.join("src/index.mts"),
3350 "export function helper() {}\n",
3351 )
3352 .unwrap();
3353 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
3354
3355 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
3356 assert!(
3357 resolved.ends_with("src/index.mts"),
3358 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
3359 );
3360 }
3361
3362 #[test]
3363 fn same_named_methods_use_scoped_symbol_identity() {
3364 let dir = TempDir::new().unwrap();
3365 fs::write(
3366 dir.path().join("classes.ts"),
3367 r#"class A {
3368 run() { helperA(); }
3369}
3370
3371class B {
3372 run() { helperB(); }
3373}
3374
3375function helperA() {}
3376function helperB() {}
3377"#,
3378 )
3379 .unwrap();
3380
3381 let mut graph = CallGraph::new(dir.path().to_path_buf());
3382 let path = dir.path().join("classes.ts");
3383 let data = graph.build_file(&path).unwrap();
3384
3385 assert!(
3386 data.symbol_metadata.contains_key("A::run"),
3387 "A::run metadata missing"
3388 );
3389 assert!(
3390 data.symbol_metadata.contains_key("B::run"),
3391 "B::run metadata missing"
3392 );
3393 assert!(
3394 data.calls_by_symbol["A::run"]
3395 .iter()
3396 .any(|call| call.callee_name == "helperA"),
3397 "A::run calls should not be overwritten"
3398 );
3399 assert!(
3400 data.calls_by_symbol["B::run"]
3401 .iter()
3402 .any(|call| call.callee_name == "helperB"),
3403 "B::run calls should not be overwritten"
3404 );
3405 }
3406
3407 #[test]
3410 fn extract_parameters_typescript() {
3411 let params = extract_parameters(
3412 "function processData(input: string, count: number): void",
3413 LangId::TypeScript,
3414 );
3415 assert_eq!(params, vec!["input", "count"]);
3416 }
3417
3418 #[test]
3419 fn extract_parameters_typescript_optional() {
3420 let params = extract_parameters(
3421 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3422 LangId::TypeScript,
3423 );
3424 assert_eq!(params, vec!["url", "options"]);
3425 }
3426
3427 #[test]
3428 fn extract_parameters_typescript_defaults() {
3429 let params = extract_parameters(
3430 "function greet(name: string, greeting: string = \"hello\"): string",
3431 LangId::TypeScript,
3432 );
3433 assert_eq!(params, vec!["name", "greeting"]);
3434 }
3435
3436 #[test]
3437 fn extract_parameters_typescript_rest() {
3438 let params = extract_parameters(
3439 "function sum(...numbers: number[]): number",
3440 LangId::TypeScript,
3441 );
3442 assert_eq!(params, vec!["numbers"]);
3443 }
3444
3445 #[test]
3446 fn extract_parameters_python_self_skipped() {
3447 let params = extract_parameters(
3448 "def process(self, data: str, count: int) -> bool",
3449 LangId::Python,
3450 );
3451 assert_eq!(params, vec!["data", "count"]);
3452 }
3453
3454 #[test]
3455 fn extract_parameters_python_no_self() {
3456 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3457 assert_eq!(params, vec!["input"]);
3458 }
3459
3460 #[test]
3461 fn extract_parameters_python_star_args() {
3462 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3463 assert_eq!(params, vec!["args", "kwargs"]);
3464 }
3465
3466 #[test]
3467 fn extract_parameters_rust_self_skipped() {
3468 let params = extract_parameters(
3469 "fn process(&self, data: &str, count: usize) -> bool",
3470 LangId::Rust,
3471 );
3472 assert_eq!(params, vec!["data", "count"]);
3473 }
3474
3475 #[test]
3476 fn extract_parameters_rust_mut_self_skipped() {
3477 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3478 assert_eq!(params, vec!["value"]);
3479 }
3480
3481 #[test]
3482 fn extract_parameters_rust_no_self() {
3483 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3484 assert_eq!(params, vec!["input"]);
3485 }
3486
3487 #[test]
3488 fn extract_parameters_rust_mut_param() {
3489 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3490 assert_eq!(params, vec!["buf", "len"]);
3491 }
3492
3493 #[test]
3494 fn extract_parameters_go() {
3495 let params = extract_parameters(
3496 "func ProcessData(input string, count int) error",
3497 LangId::Go,
3498 );
3499 assert_eq!(params, vec!["input", "count"]);
3500 }
3501
3502 #[test]
3503 fn extract_parameters_empty() {
3504 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3505 assert!(
3506 params.is_empty(),
3507 "no-arg function should return empty params"
3508 );
3509 }
3510
3511 #[test]
3512 fn extract_parameters_no_parens() {
3513 let params = extract_parameters("const x = 42", LangId::TypeScript);
3514 assert!(params.is_empty(), "no parens should return empty params");
3515 }
3516
3517 #[test]
3518 fn extract_parameters_javascript() {
3519 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3520 assert_eq!(params, vec!["event", "target"]);
3521 }
3522}