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