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