1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_json::Value;
16use tree_sitter::{Node, Parser};
17
18use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
19use crate::edit::line_col_to_byte;
20use crate::error::AftError;
21use crate::imports::{self, ImportBlock};
22use crate::language::LanguageProvider;
23use crate::parser::{detect_language, grammar_for, LangId};
24use crate::symbols::{Range, Symbol, SymbolKind};
25
26type SharedPath = Arc<PathBuf>;
31type SharedStr = Arc<str>;
32type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
33type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36 LazyLock::new(|| RwLock::new(HashMap::new()));
37
38const TOP_LEVEL_SYMBOL: &str = "<top-level>";
39const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
40const JS_TS_INDEX_FILES: &[&str] = &[
41 "index.ts",
42 "index.tsx",
43 "index.mts",
44 "index.cts",
45 "index.js",
46 "index.jsx",
47 "index.mjs",
48 "index.cjs",
49];
50
51fn symbol_identity(symbol: &Symbol) -> String {
52 if symbol.scope_chain.is_empty() {
53 symbol.name.clone()
54 } else {
55 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
56 }
57}
58
59fn symbol_unqualified_name(symbol: &str) -> &str {
60 symbol.rsplit("::").next().unwrap_or(symbol)
61}
62
63fn symbol_query_matches(symbol: &str, query: &str) -> bool {
64 symbol == query || symbol_unqualified_name(symbol) == query
65}
66
67fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
68 full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
69}
70
71fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
72 let mut seen = HashSet::new();
73 let mut candidates = Vec::new();
74 let qualified_query = symbol_name.contains("::");
75
76 let mut consider = |candidate: &str| {
77 let matches = if qualified_query {
78 candidate == symbol_name
79 } else {
80 candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
81 };
82
83 if matches && seen.insert(candidate.to_string()) {
84 candidates.push(candidate.to_string());
85 }
86 };
87
88 for candidate in file_data.symbol_metadata.keys() {
89 consider(candidate);
90 }
91 for candidate in file_data.calls_by_symbol.keys() {
92 consider(candidate);
93 }
94 for candidate in &file_data.exported_symbols {
95 consider(candidate);
96 }
97
98 candidates.sort();
99 candidates
100}
101
102fn resolve_symbol_query_in_data(
103 file_data: &FileCallData,
104 file: &Path,
105 symbol_name: &str,
106) -> Result<String, AftError> {
107 let candidates = symbol_query_candidates(file_data, symbol_name);
108 match candidates.as_slice() {
109 [candidate] => Ok(candidate.clone()),
110 [] => Err(AftError::SymbolNotFound {
111 name: symbol_name.to_string(),
112 file: file.display().to_string(),
113 }),
114 _ => Err(AftError::AmbiguousSymbol {
115 name: symbol_name.to_string(),
116 candidates,
117 }),
118 }
119}
120
121#[derive(Debug, Clone)]
123pub struct CallSite {
124 pub callee_name: String,
126 pub full_callee: String,
128 pub line: u32,
130 pub byte_start: usize,
132 pub byte_end: usize,
133}
134
135#[derive(Debug, Clone, Serialize)]
137pub struct SymbolMeta {
138 pub kind: SymbolKind,
140 pub exported: bool,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub signature: Option<String>,
145 pub line: u32,
147 pub range: Range,
149}
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
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum EdgeResolution {
172 Resolved { file: PathBuf, symbol: String },
174 Unresolved { callee_name: String },
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179struct ResolvedSymbol {
180 file: PathBuf,
181 symbol: String,
182}
183
184#[derive(Debug, Clone, Serialize)]
186pub struct CallerSite {
187 pub caller_file: PathBuf,
189 pub caller_symbol: String,
191 pub line: u32,
193 pub col: u32,
195 pub resolved: bool,
197}
198
199#[derive(Debug, Clone)]
200struct IndexedCallerSite {
201 caller_file: SharedPath,
202 caller_symbol: SharedStr,
203 line: u32,
204 col: u32,
205 resolved: bool,
206}
207
208#[derive(Debug, Clone, Serialize)]
210pub struct CallerGroup {
211 pub file: String,
213 pub callers: Vec<CallerEntry>,
215}
216
217#[derive(Debug, Clone, Serialize)]
219pub struct CallerEntry {
220 pub symbol: String,
221 pub line: u32,
223}
224
225#[derive(Debug, Clone, Serialize)]
227pub struct CallersResult {
228 pub symbol: String,
230 pub file: String,
232 pub callers: Vec<CallerGroup>,
234 pub total_callers: usize,
236 pub scanned_files: usize,
238 pub depth_limited: bool,
240 pub truncated: usize,
242}
243
244#[derive(Debug, Clone, Serialize)]
246pub struct CallTreeNode {
247 pub name: String,
249 pub file: String,
251 pub line: u32,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub signature: Option<String>,
256 pub resolved: bool,
258 pub children: Vec<CallTreeNode>,
260 pub depth_limited: bool,
262 pub truncated: usize,
264}
265
266const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
272
273pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
280 if exported && *kind == SymbolKind::Function {
282 return true;
283 }
284
285 let lower = name.to_lowercase();
287 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
288 return true;
289 }
290
291 match lang {
293 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
294 matches!(lower.as_str(), "describe" | "it" | "test")
296 || lower.starts_with("test")
297 || lower.starts_with("spec")
298 }
299 LangId::Python => {
300 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
302 }
303 LangId::Rust => {
304 lower.starts_with("test_")
306 }
307 LangId::Go => {
308 name.starts_with("Test")
310 }
311 LangId::C
312 | LangId::Cpp
313 | LangId::Zig
314 | LangId::CSharp
315 | LangId::Bash
316 | LangId::Solidity
317 | LangId::Vue
318 | LangId::Json
319 | LangId::Scala
320 | LangId::Java
321 | LangId::Ruby
322 | LangId::Kotlin
323 | LangId::Swift
324 | LangId::Php
325 | LangId::Lua
326 | LangId::Perl
327 | LangId::Html
328 | LangId::Markdown => false,
329 }
330}
331
332#[derive(Debug, Clone, Serialize)]
338pub struct TraceHop {
339 pub symbol: String,
341 pub file: String,
343 pub line: u32,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub signature: Option<String>,
348 pub is_entry_point: bool,
350}
351
352#[derive(Debug, Clone, Serialize)]
354pub struct TracePath {
355 pub hops: Vec<TraceHop>,
357}
358
359#[derive(Debug, Clone, Serialize)]
361pub struct TraceToResult {
362 pub target_symbol: String,
364 pub target_file: String,
366 pub paths: Vec<TracePath>,
368 pub total_paths: usize,
370 pub entry_points_found: usize,
372 pub max_depth_reached: bool,
374 pub truncated_paths: usize,
376}
377
378#[derive(Debug, Clone, Serialize)]
380pub struct TraceToSymbolHop {
381 pub symbol: String,
383 pub file: String,
385 pub line: u32,
387}
388
389#[derive(Debug, Clone, Serialize)]
391pub struct TraceToSymbolCandidate {
392 pub file: String,
394 pub line: u32,
396}
397
398#[derive(Debug, Clone, Serialize)]
400pub struct TraceToSymbolResult {
401 pub path: Option<Vec<TraceToSymbolHop>>,
403 pub complete: bool,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub reason: Option<String>,
408}
409
410#[derive(Debug, Clone, Serialize)]
416pub struct ImpactCaller {
417 pub caller_symbol: String,
419 pub caller_file: String,
421 pub line: u32,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub signature: Option<String>,
426 pub is_entry_point: bool,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub call_expression: Option<String>,
431 pub parameters: Vec<String>,
433}
434
435#[derive(Debug, Clone, Serialize)]
437pub struct ImpactResult {
438 pub symbol: String,
440 pub file: String,
442 #[serde(skip_serializing_if = "Option::is_none")]
444 pub signature: Option<String>,
445 pub parameters: Vec<String>,
447 pub total_affected: usize,
449 pub affected_files: usize,
451 pub callers: Vec<ImpactCaller>,
453 pub depth_limited: bool,
455 pub truncated: usize,
457}
458
459#[derive(Debug, Clone, Serialize)]
465pub struct DataFlowHop {
466 pub file: String,
468 pub symbol: String,
470 pub variable: String,
472 pub line: u32,
474 pub flow_type: String,
476 pub approximate: bool,
478}
479
480#[derive(Debug, Clone, Serialize)]
483pub struct TraceDataResult {
484 pub expression: String,
486 pub origin_file: String,
488 pub origin_symbol: String,
490 pub hops: Vec<DataFlowHop>,
492 pub depth_limited: bool,
494}
495
496pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
502 let start = match signature.find('(') {
504 Some(i) => i + 1,
505 None => return Vec::new(),
506 };
507 let end = match signature[start..].find(')') {
508 Some(i) => start + i,
509 None => return Vec::new(),
510 };
511
512 let params_str = &signature[start..end].trim();
513 if params_str.is_empty() {
514 return Vec::new();
515 }
516
517 let parts = split_params(params_str);
519
520 let mut result = Vec::new();
521 for part in parts {
522 let trimmed = part.trim();
523 if trimmed.is_empty() {
524 continue;
525 }
526
527 match lang {
529 LangId::Rust => {
530 if trimmed == "self"
531 || trimmed == "mut self"
532 || trimmed.starts_with("&self")
533 || trimmed.starts_with("&mut self")
534 {
535 continue;
536 }
537 }
538 LangId::Python => {
539 if trimmed == "self" || trimmed.starts_with("self:") {
540 continue;
541 }
542 }
543 _ => {}
544 }
545
546 let name = extract_param_name(trimmed, lang);
548 if !name.is_empty() {
549 result.push(name);
550 }
551 }
552
553 result
554}
555
556fn split_params(s: &str) -> Vec<String> {
558 let mut parts = Vec::new();
559 let mut current = String::new();
560 let mut depth = 0i32;
561
562 for ch in s.chars() {
563 match ch {
564 '<' | '[' | '{' | '(' => {
565 depth += 1;
566 current.push(ch);
567 }
568 '>' | ']' | '}' | ')' => {
569 depth -= 1;
570 current.push(ch);
571 }
572 ',' if depth == 0 => {
573 parts.push(current.clone());
574 current.clear();
575 }
576 _ => {
577 current.push(ch);
578 }
579 }
580 }
581 if !current.is_empty() {
582 parts.push(current);
583 }
584 parts
585}
586
587fn extract_param_name(param: &str, lang: LangId) -> String {
595 let trimmed = param.trim();
596
597 let working = if trimmed.starts_with("...") {
599 &trimmed[3..]
600 } else if trimmed.starts_with("**") {
601 &trimmed[2..]
602 } else if trimmed.starts_with('*') && lang == LangId::Python {
603 &trimmed[1..]
604 } else {
605 trimmed
606 };
607
608 let working = if lang == LangId::Rust && working.starts_with("mut ") {
610 &working[4..]
611 } else {
612 working
613 };
614
615 let name = working
618 .split(|c: char| c == ':' || c == '=')
619 .next()
620 .unwrap_or("")
621 .trim();
622
623 let name = name.trim_end_matches('?');
625
626 if lang == LangId::Go && !name.contains(' ') {
628 return name.to_string();
629 }
630 if lang == LangId::Go {
631 return name.split_whitespace().next().unwrap_or("").to_string();
632 }
633
634 name.to_string()
635}
636
637pub struct CallGraph {
646 data: HashMap<PathBuf, FileCallData>,
648 project_root: PathBuf,
650 project_files: Option<Vec<PathBuf>>,
652 reverse_index: Option<ReverseIndex>,
655}
656
657impl CallGraph {
658 pub fn new(project_root: PathBuf) -> Self {
660 clear_workspace_package_cache();
661 Self {
662 data: HashMap::new(),
663 project_root,
664 project_files: None,
665 reverse_index: None,
666 }
667 }
668
669 pub fn project_root(&self) -> &Path {
671 &self.project_root
672 }
673
674 fn resolve_cross_file_edge_with_exports<F, D>(
675 full_callee: &str,
676 short_name: &str,
677 caller_file: &Path,
678 import_block: &ImportBlock,
679 mut file_exports_symbol: F,
680 mut file_default_export_symbol: D,
681 ) -> EdgeResolution
682 where
683 F: FnMut(&Path, &str) -> bool,
684 D: FnMut(&Path) -> Option<String>,
685 {
686 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
687
688 if full_callee.contains('.') {
690 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
691 if parts.len() == 2 {
692 let namespace = parts[0];
693 let member = parts[1];
694
695 for imp in &import_block.imports {
696 if imp.namespace_import.as_deref() == Some(namespace) {
697 if let Some(resolved_path) =
698 resolve_module_path(caller_dir, &imp.module_path)
699 {
700 if let Some(target) = resolve_reexported_symbol(
701 &resolved_path,
702 member,
703 &mut file_exports_symbol,
704 &mut file_default_export_symbol,
705 ) {
706 return EdgeResolution::Resolved {
707 file: target.file,
708 symbol: target.symbol,
709 };
710 }
711 }
712 }
713 }
714 }
715 }
716
717 for imp in &import_block.imports {
719 if imp.names.iter().any(|name| name == short_name) {
721 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
722 let target = resolve_reexported_symbol(
723 &resolved_path,
724 short_name,
725 &mut file_exports_symbol,
726 &mut file_default_export_symbol,
727 )
728 .unwrap_or(ResolvedSymbol {
729 file: resolved_path,
730 symbol: short_name.to_owned(),
731 });
732 return EdgeResolution::Resolved {
733 file: target.file,
734 symbol: target.symbol,
735 };
736 }
737 }
738
739 if imp.default_import.as_deref() == Some(short_name) {
741 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
742 let target = resolve_reexported_symbol(
743 &resolved_path,
744 "default",
745 &mut file_exports_symbol,
746 &mut file_default_export_symbol,
747 )
748 .unwrap_or_else(|| ResolvedSymbol {
749 symbol: file_default_export_symbol(&resolved_path)
750 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
751 file: resolved_path,
752 });
753 return EdgeResolution::Resolved {
754 file: target.file,
755 symbol: target.symbol,
756 };
757 }
758 }
759 }
760
761 if let Some((original_name, resolved_path)) =
766 resolve_aliased_import(short_name, import_block, caller_dir)
767 {
768 let target = resolve_reexported_symbol(
769 &resolved_path,
770 &original_name,
771 &mut file_exports_symbol,
772 &mut file_default_export_symbol,
773 )
774 .unwrap_or(ResolvedSymbol {
775 file: resolved_path,
776 symbol: original_name,
777 });
778 return EdgeResolution::Resolved {
779 file: target.file,
780 symbol: target.symbol,
781 };
782 }
783
784 for imp in &import_block.imports {
787 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
788 if resolved_path.is_dir() {
790 if let Some(index_path) = find_index_file(&resolved_path) {
791 if file_exports_symbol(&index_path, short_name) {
793 return EdgeResolution::Resolved {
794 file: index_path,
795 symbol: short_name.to_owned(),
796 };
797 }
798 }
799 } else if file_exports_symbol(&resolved_path, short_name) {
800 return EdgeResolution::Resolved {
801 file: resolved_path,
802 symbol: short_name.to_owned(),
803 };
804 }
805 }
806 }
807
808 EdgeResolution::Unresolved {
809 callee_name: short_name.to_owned(),
810 }
811 }
812
813 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
815 let canon = self.canonicalize(path)?;
816
817 if !self.data.contains_key(&canon) {
818 let file_data = build_file_data(&canon)?;
819 self.data.insert(canon.clone(), file_data);
820 }
821
822 Ok(&self.data[&canon])
823 }
824
825 pub fn resolve_symbol_query(&mut self, file: &Path, symbol: &str) -> Result<String, AftError> {
828 let canon = self.canonicalize(file)?;
829 let file_data = self.build_file(&canon)?;
830 resolve_symbol_query_in_data(file_data, &canon, symbol)
831 }
832
833 pub fn resolve_cross_file_edge(
838 &mut self,
839 full_callee: &str,
840 short_name: &str,
841 caller_file: &Path,
842 import_block: &ImportBlock,
843 ) -> EdgeResolution {
844 let graph = RefCell::new(self);
845 Self::resolve_cross_file_edge_with_exports(
846 full_callee,
847 short_name,
848 caller_file,
849 import_block,
850 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
851 |path| graph.borrow_mut().file_default_export_symbol(path),
852 )
853 }
854
855 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
857 match self.build_file(path) {
858 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
859 Err(_) => false,
860 }
861 }
862
863 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
864 self.build_file(path)
865 .ok()
866 .and_then(|data| data.default_export_symbol.clone())
867 }
868
869 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
870 self.lookup_file_data(path)
871 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
872 .unwrap_or(false)
873 }
874
875 fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
876 self.lookup_file_data(path)
877 .and_then(|data| data.default_export_symbol.clone())
878 }
879
880 pub fn forward_tree(
885 &mut self,
886 file: &Path,
887 symbol: &str,
888 max_depth: usize,
889 ) -> Result<CallTreeNode, AftError> {
890 let canon = self.canonicalize(file)?;
891 let resolved_symbol = {
892 let file_data = self.build_file(&canon)?;
893 resolve_symbol_query_in_data(file_data, &canon, symbol)?
894 };
895 let mut visited = HashSet::new();
896 self.forward_tree_inner(&canon, &resolved_symbol, max_depth, 0, &mut visited)
897 }
898
899 fn forward_tree_inner(
900 &mut self,
901 file: &Path,
902 symbol: &str,
903 max_depth: usize,
904 current_depth: usize,
905 visited: &mut HashSet<(PathBuf, String)>,
906 ) -> Result<CallTreeNode, AftError> {
907 let canon = self.canonicalize(file)?;
908 let visit_key = (canon.clone(), symbol.to_string());
909
910 if visited.contains(&visit_key) {
912 let (line, signature) = self
913 .lookup_file_data(&canon)
914 .map(|data| get_symbol_meta_from_data(data, symbol))
915 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
916 return Ok(CallTreeNode {
917 name: symbol.to_string(),
918 file: self.relative_path(&canon),
919 line,
920 signature,
921 resolved: true,
922 children: vec![], depth_limited: false,
924 truncated: 0,
925 });
926 }
927
928 visited.insert(visit_key.clone());
929
930 let (import_block, call_sites, sym_line, sym_signature) = {
931 let file_data = self.build_file(&canon)?;
932 let meta = get_symbol_meta_from_data(file_data, symbol);
933
934 (
935 file_data.import_block.clone(),
936 file_data
937 .calls_by_symbol
938 .get(symbol)
939 .cloned()
940 .unwrap_or_default(),
941 meta.0,
942 meta.1,
943 )
944 };
945
946 let mut children = Vec::new();
948 let mut depth_limited = false;
949 let mut truncated = 0;
950
951 if current_depth < max_depth {
952 for call_site in &call_sites {
953 let edge = self.resolve_cross_file_edge(
954 &call_site.full_callee,
955 &call_site.callee_name,
956 &canon,
957 &import_block,
958 );
959
960 match edge {
961 EdgeResolution::Resolved {
962 file: ref target_file,
963 ref symbol,
964 } => {
965 match self.forward_tree_inner(
966 target_file,
967 symbol,
968 max_depth,
969 current_depth + 1,
970 visited,
971 ) {
972 Ok(child) => {
973 depth_limited |= child.depth_limited;
974 truncated += child.truncated;
975 children.push(child);
976 }
977 Err(_) => {
978 children.push(CallTreeNode {
980 name: call_site.callee_name.clone(),
981 file: self.relative_path(target_file),
982 line: call_site.line,
983 signature: None,
984 resolved: false,
985 children: vec![],
986 depth_limited: false,
987 truncated: 0,
988 });
989 }
990 }
991 }
992 EdgeResolution::Unresolved { callee_name } => {
993 if let Some(local_child) = self.resolve_local_call_tree_child(
994 &canon,
995 symbol,
996 call_site,
997 &callee_name,
998 max_depth,
999 current_depth,
1000 visited,
1001 )? {
1002 depth_limited |= local_child.depth_limited;
1003 truncated += local_child.truncated;
1004 children.push(local_child);
1005 continue;
1006 }
1007 children.push(CallTreeNode {
1008 name: callee_name,
1009 file: self.relative_path(&canon),
1010 line: call_site.line,
1011 signature: None,
1012 resolved: false,
1013 children: vec![],
1014 depth_limited: false,
1015 truncated: 0,
1016 });
1017 }
1018 }
1019 }
1020 } else if !call_sites.is_empty() {
1021 depth_limited = true;
1022 truncated = call_sites.len();
1023 }
1024
1025 visited.remove(&visit_key);
1026
1027 Ok(CallTreeNode {
1028 name: symbol.to_string(),
1029 file: self.relative_path(&canon),
1030 line: sym_line,
1031 signature: sym_signature,
1032 resolved: true,
1033 children,
1034 depth_limited,
1035 truncated,
1036 })
1037 }
1038
1039 fn resolve_local_call_tree_child(
1040 &mut self,
1041 canon: &Path,
1042 current_symbol: &str,
1043 call_site: &CallSite,
1044 callee_name: &str,
1045 max_depth: usize,
1046 current_depth: usize,
1047 visited: &mut HashSet<(PathBuf, String)>,
1048 ) -> Result<Option<CallTreeNode>, AftError> {
1049 if !is_bare_callee(&call_site.full_callee, callee_name) {
1050 return Ok(None);
1051 }
1052
1053 let target_symbol = match self
1054 .lookup_file_data(canon)
1055 .and_then(|data| resolve_symbol_query_in_data(data, canon, callee_name).ok())
1056 {
1057 Some(symbol) => symbol,
1058 None => return Ok(None),
1059 };
1060
1061 if target_symbol == current_symbol {
1062 return Ok(None);
1063 }
1064
1065 match self.forward_tree_inner(canon, &target_symbol, max_depth, current_depth + 1, visited)
1066 {
1067 Ok(child) => Ok(Some(child)),
1068 Err(_) => Ok(Some(CallTreeNode {
1069 name: target_symbol,
1070 file: self.relative_path(canon),
1071 line: call_site.line,
1072 signature: None,
1073 resolved: false,
1074 children: vec![],
1075 depth_limited: false,
1076 truncated: 0,
1077 })),
1078 }
1079 }
1080
1081 pub fn project_files(&mut self) -> &[PathBuf] {
1083 if self.project_files.is_none() {
1084 let project_root = self.project_root.clone();
1085 self.project_files = Some(walk_project_files(&project_root).collect());
1086 }
1087 self.project_files.as_deref().unwrap_or(&[])
1088 }
1089
1090 pub fn project_file_count(&mut self) -> usize {
1096 self.project_files().len()
1097 }
1098
1099 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
1110 if let Some(files) = self.project_files.as_deref() {
1111 return files.len();
1112 }
1113 walk_project_files(&self.project_root)
1114 .take(limit.saturating_add(1))
1115 .count()
1116 }
1117
1118 fn ensure_project_files_built(&mut self, max_files: usize) -> Result<(), AftError> {
1121 let count = self.project_file_count_bounded(max_files);
1126 if count > max_files {
1127 return Err(AftError::ProjectTooLarge {
1128 count,
1129 max: max_files,
1130 });
1131 }
1132
1133 let all_files = self.project_files().to_vec();
1137
1138 let uncached_files: Vec<PathBuf> = all_files
1140 .iter()
1141 .filter(|f| self.lookup_file_data(f).is_none())
1142 .cloned()
1143 .collect();
1144
1145 let computed: Vec<(PathBuf, FileCallData)> = uncached_files
1146 .par_iter()
1147 .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
1148 .collect();
1149
1150 for (file, data) in computed {
1151 self.data.insert(file, data);
1152 }
1153
1154 Ok(())
1155 }
1156
1157 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
1163 self.ensure_project_files_built(max_files)?;
1164 let all_files = self.project_files().to_vec();
1165
1166 let mut reverse: ReverseIndex = HashMap::new();
1168
1169 for caller_file in &all_files {
1170 let canon_caller = Arc::new(
1172 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
1173 );
1174 let file_data = match self
1175 .data
1176 .get(caller_file)
1177 .or_else(|| self.data.get(canon_caller.as_ref()))
1178 {
1179 Some(d) => d,
1180 None => continue,
1181 };
1182
1183 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
1184 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
1185
1186 for call_site in call_sites {
1187 let edge = Self::resolve_cross_file_edge_with_exports(
1188 &call_site.full_callee,
1189 &call_site.callee_name,
1190 canon_caller.as_ref(),
1191 &file_data.import_block,
1192 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1193 |path| self.file_default_export_symbol_cached(path),
1194 );
1195
1196 let (target_file, target_symbol, resolved) = match edge {
1197 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1198 EdgeResolution::Unresolved { callee_name } => {
1199 if !is_bare_callee(&call_site.full_callee, &callee_name) {
1200 continue;
1201 }
1202
1203 let Ok(target_symbol) = resolve_symbol_query_in_data(
1204 file_data,
1205 canon_caller.as_ref(),
1206 &callee_name,
1207 ) else {
1208 continue;
1209 };
1210
1211 (canon_caller.as_ref().clone(), target_symbol, false)
1212 }
1213 };
1214
1215 if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1216 continue;
1217 }
1218
1219 reverse
1220 .entry(target_file)
1221 .or_default()
1222 .entry(target_symbol)
1223 .or_default()
1224 .push(IndexedCallerSite {
1225 caller_file: Arc::clone(&canon_caller),
1226 caller_symbol: Arc::clone(&caller_symbol),
1227 line: call_site.line,
1228 col: 0,
1229 resolved,
1230 });
1231 }
1232 }
1233 }
1234
1235 self.reverse_index = Some(reverse);
1236 Ok(())
1237 }
1238
1239 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1240 self.reverse_index
1241 .as_ref()?
1242 .get(file)?
1243 .get(symbol)
1244 .map(Vec::as_slice)
1245 }
1246
1247 pub fn callers_of(
1253 &mut self,
1254 file: &Path,
1255 symbol: &str,
1256 depth: usize,
1257 max_files: usize,
1258 ) -> Result<CallersResult, AftError> {
1259 let canon = self.canonicalize(file)?;
1260
1261 let resolved_symbol = {
1263 let file_data = self.build_file(&canon)?;
1264 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1265 };
1266
1267 if self.reverse_index.is_none() {
1269 self.build_reverse_index(max_files)?;
1270 }
1271
1272 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1273 let effective_depth = if depth == 0 { 1 } else { depth };
1274
1275 let mut visited = HashSet::new();
1276 let mut all_sites: Vec<CallerSite> = Vec::new();
1277 let mut depth_limited = false;
1278 let mut truncated = 0;
1279 self.collect_callers_recursive(
1280 &canon,
1281 &resolved_symbol,
1282 effective_depth,
1283 0,
1284 &mut visited,
1285 &mut all_sites,
1286 &mut depth_limited,
1287 &mut truncated,
1288 );
1289
1290 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1293 let total_callers = all_sites.len();
1294 for site in all_sites {
1295 let caller_file: PathBuf = site.caller_file;
1296 let caller_symbol: String = site.caller_symbol;
1297 let line = site.line;
1298 let entry = CallerEntry {
1299 symbol: caller_symbol,
1300 line,
1301 };
1302
1303 if let Some(entries) = groups_map.get_mut(&caller_file) {
1304 entries.push(entry);
1305 } else {
1306 groups_map.insert(caller_file, vec![entry]);
1307 }
1308 }
1309
1310 let mut callers: Vec<CallerGroup> = groups_map
1311 .into_iter()
1312 .map(|(file_path, entries)| CallerGroup {
1313 file: self.relative_path(&file_path),
1314 callers: entries,
1315 })
1316 .collect();
1317
1318 callers.sort_by(|a, b| a.file.cmp(&b.file));
1320
1321 Ok(CallersResult {
1322 symbol: resolved_symbol,
1323 file: self.relative_path(&canon),
1324 callers,
1325 total_callers,
1326 scanned_files,
1327 depth_limited,
1328 truncated,
1329 })
1330 }
1331
1332 pub fn trace_to(
1338 &mut self,
1339 file: &Path,
1340 symbol: &str,
1341 max_depth: usize,
1342 max_files: usize,
1343 ) -> Result<TraceToResult, AftError> {
1344 let canon = self.canonicalize(file)?;
1345
1346 let resolved_symbol = {
1348 let file_data = self.build_file(&canon)?;
1349 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1350 };
1351
1352 if self.reverse_index.is_none() {
1354 self.build_reverse_index(max_files)?;
1355 }
1356
1357 let target_rel = self.relative_path(&canon);
1358 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1359 if self.reverse_index.is_none() {
1360 return Err(AftError::ParseError {
1361 message: format!(
1362 "reverse index unavailable after building callers for {}",
1363 canon.display()
1364 ),
1365 });
1366 }
1367
1368 let (target_line, target_sig) = self
1370 .lookup_file_data(&canon)
1371 .map(|data| get_symbol_meta_from_data(data, &resolved_symbol))
1372 .unwrap_or_else(|| get_symbol_meta(&canon, &resolved_symbol));
1373
1374 let target_is_entry = self
1376 .lookup_file_data(&canon)
1377 .and_then(|fd| {
1378 let meta = fd.symbol_metadata.get(&resolved_symbol)?;
1379 Some(is_entry_point(
1380 &resolved_symbol,
1381 &meta.kind,
1382 meta.exported,
1383 fd.lang,
1384 ))
1385 })
1386 .unwrap_or(false);
1387
1388 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1391 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1392 let mut max_depth_reached = false;
1393 let mut truncated_paths: usize = 0;
1394
1395 let initial: Vec<PathElem> = vec![(
1397 Arc::new(canon.clone()),
1398 Arc::from(resolved_symbol.as_str()),
1399 target_line,
1400 target_sig,
1401 )];
1402
1403 if target_is_entry {
1405 complete_paths.push(initial.clone());
1406 }
1407
1408 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1410
1411 while let Some((path, depth)) = queue.pop() {
1412 if depth >= effective_max {
1413 max_depth_reached = true;
1414 continue;
1415 }
1416
1417 let Some((current_file, current_symbol, _, _)) = path.last() else {
1418 continue;
1419 };
1420
1421 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1423 Some(sites) => sites,
1424 None => {
1425 if path.len() > 1 {
1428 truncated_paths += 1;
1431 }
1432 continue;
1433 }
1434 };
1435
1436 let mut has_new_path = false;
1437 for site in callers {
1438 if path.iter().any(|(file_path, sym, _, _)| {
1440 file_path.as_ref() == site.caller_file.as_ref()
1441 && sym.as_ref() == site.caller_symbol.as_ref()
1442 }) {
1443 continue;
1444 }
1445
1446 has_new_path = true;
1447
1448 let (caller_line, caller_sig) = self
1450 .lookup_file_data(site.caller_file.as_ref())
1451 .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1452 .unwrap_or_else(|| {
1453 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1454 });
1455
1456 let mut new_path = path.clone();
1457 new_path.push((
1458 Arc::clone(&site.caller_file),
1459 Arc::clone(&site.caller_symbol),
1460 caller_line,
1461 caller_sig,
1462 ));
1463
1464 let caller_is_entry = self
1468 .lookup_file_data(site.caller_file.as_ref())
1469 .and_then(|fd| {
1470 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1471 Some(is_entry_point(
1472 site.caller_symbol.as_ref(),
1473 &meta.kind,
1474 meta.exported,
1475 fd.lang,
1476 ))
1477 })
1478 .unwrap_or(false);
1479
1480 if caller_is_entry {
1481 complete_paths.push(new_path.clone());
1482 }
1483 queue.push((new_path, depth + 1));
1486 }
1487
1488 if !has_new_path && path.len() > 1 {
1490 truncated_paths += 1;
1491 }
1492 }
1493
1494 let mut paths: Vec<TracePath> = complete_paths
1497 .into_iter()
1498 .map(|mut elems| {
1499 elems.reverse();
1500 let hops: Vec<TraceHop> = elems
1501 .iter()
1502 .enumerate()
1503 .map(|(i, (file_path, sym, line, sig))| {
1504 let is_ep = if i == 0 {
1505 self.lookup_file_data(file_path.as_ref())
1507 .and_then(|fd| {
1508 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1509 Some(is_entry_point(
1510 sym.as_ref(),
1511 &meta.kind,
1512 meta.exported,
1513 fd.lang,
1514 ))
1515 })
1516 .unwrap_or(false)
1517 } else {
1518 false
1519 };
1520 TraceHop {
1521 symbol: sym.to_string(),
1522 file: self.relative_path(file_path.as_ref()),
1523 line: *line,
1524 signature: sig.clone(),
1525 is_entry_point: is_ep,
1526 }
1527 })
1528 .collect();
1529 TracePath { hops }
1530 })
1531 .collect();
1532
1533 paths.sort_by(|a, b| {
1535 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1536 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1537 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1538 });
1539
1540 let mut entry_points: HashSet<(String, String)> = HashSet::new();
1542 for p in &paths {
1543 if let Some(first) = p.hops.first() {
1544 if first.is_entry_point {
1545 entry_points.insert((first.file.clone(), first.symbol.clone()));
1546 }
1547 }
1548 }
1549
1550 let total_paths = paths.len();
1551 let entry_points_found = entry_points.len();
1552
1553 Ok(TraceToResult {
1554 target_symbol: resolved_symbol,
1555 target_file: target_rel,
1556 paths,
1557 total_paths,
1558 entry_points_found,
1559 max_depth_reached,
1560 truncated_paths,
1561 })
1562 }
1563
1564 pub fn trace_to_symbol_candidates(
1569 &mut self,
1570 to_symbol: &str,
1571 max_files: usize,
1572 ) -> Result<Vec<TraceToSymbolCandidate>, AftError> {
1573 self.ensure_project_files_built(max_files)?;
1574
1575 let mut candidates_by_file: HashMap<PathBuf, u32> = HashMap::new();
1576 let all_files = self.project_files().to_vec();
1577
1578 for file in all_files {
1579 let canon = self.canonicalize(&file)?;
1580 let Some(file_data) = self
1581 .lookup_file_data(&canon)
1582 .or_else(|| self.lookup_file_data(&file))
1583 else {
1584 continue;
1585 };
1586
1587 let symbol_candidates = symbol_query_candidates(file_data, to_symbol);
1588 if symbol_candidates.is_empty() {
1589 continue;
1590 }
1591
1592 let line = symbol_candidates
1593 .iter()
1594 .filter_map(|symbol| file_data.symbol_metadata.get(symbol).map(|meta| meta.line))
1595 .min()
1596 .unwrap_or(1);
1597
1598 candidates_by_file
1599 .entry(canon)
1600 .and_modify(|existing| *existing = (*existing).min(line))
1601 .or_insert(line);
1602 }
1603
1604 let mut candidates: Vec<TraceToSymbolCandidate> = candidates_by_file
1605 .into_iter()
1606 .map(|(file, line)| TraceToSymbolCandidate {
1607 file: self.relative_path(&file),
1608 line,
1609 })
1610 .collect();
1611 candidates.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
1612 Ok(candidates)
1613 }
1614
1615 pub fn trace_to_symbol(
1621 &mut self,
1622 file: &Path,
1623 symbol: &str,
1624 to_symbol: &str,
1625 to_file: Option<&Path>,
1626 max_depth: usize,
1627 max_files: usize,
1628 ) -> Result<TraceToSymbolResult, AftError> {
1629 let canon = self.canonicalize(file)?;
1630
1631 let resolved_symbol = {
1633 let file_data = self.build_file(&canon)?;
1634 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1635 };
1636
1637 self.ensure_project_files_built(max_files)?;
1638
1639 let target_file = to_file.map(|path| self.canonicalize(path)).transpose()?;
1640 let effective_max = if max_depth == 0 {
1641 10
1642 } else {
1643 max_depth.min(16)
1644 };
1645
1646 let start_hop = self.trace_to_symbol_hop(&canon, &resolved_symbol);
1647 if Self::trace_to_symbol_matches_target(&canon, &resolved_symbol, to_symbol, &target_file) {
1648 return Ok(TraceToSymbolResult {
1649 path: Some(vec![start_hop]),
1650 complete: true,
1651 reason: None,
1652 });
1653 }
1654
1655 let mut queue: VecDeque<(PathBuf, String, Vec<TraceToSymbolHop>, usize)> = VecDeque::new();
1656 queue.push_back((canon.clone(), resolved_symbol.clone(), vec![start_hop], 0));
1657
1658 let mut visited: HashSet<(PathBuf, String)> = HashSet::new();
1659 visited.insert((canon, resolved_symbol));
1660 let mut max_depth_exhausted = false;
1661
1662 while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
1663 let callees = self.forward_resolved_callees(¤t_file, ¤t_symbol)?;
1664
1665 if depth >= effective_max {
1666 if callees
1667 .iter()
1668 .any(|(file, symbol)| !visited.contains(&(file.clone(), symbol.clone())))
1669 {
1670 max_depth_exhausted = true;
1671 }
1672 continue;
1673 }
1674
1675 for (callee_file, callee_symbol) in callees {
1676 let visit_key = (callee_file.clone(), callee_symbol.clone());
1677 if !visited.insert(visit_key) {
1678 continue;
1679 }
1680
1681 let mut next_path = path.clone();
1682 next_path.push(self.trace_to_symbol_hop(&callee_file, &callee_symbol));
1683
1684 if Self::trace_to_symbol_matches_target(
1685 &callee_file,
1686 &callee_symbol,
1687 to_symbol,
1688 &target_file,
1689 ) {
1690 return Ok(TraceToSymbolResult {
1691 path: Some(next_path),
1692 complete: true,
1693 reason: None,
1694 });
1695 }
1696
1697 queue.push_back((callee_file, callee_symbol, next_path, depth + 1));
1698 }
1699 }
1700
1701 if max_depth_exhausted {
1702 Ok(TraceToSymbolResult {
1703 path: None,
1704 complete: false,
1705 reason: Some("max_depth_exhausted".to_string()),
1706 })
1707 } else {
1708 Ok(TraceToSymbolResult {
1709 path: None,
1710 complete: true,
1711 reason: Some("no_path_found".to_string()),
1712 })
1713 }
1714 }
1715
1716 fn trace_to_symbol_matches_target(
1717 file: &Path,
1718 symbol: &str,
1719 to_symbol: &str,
1720 to_file: &Option<PathBuf>,
1721 ) -> bool {
1722 if !symbol_query_matches(symbol, to_symbol) {
1723 return false;
1724 }
1725
1726 if let Some(target_file) = to_file {
1727 file == target_file
1728 } else {
1729 true
1730 }
1731 }
1732
1733 fn trace_to_symbol_hop(&self, file: &Path, symbol: &str) -> TraceToSymbolHop {
1734 let (line, _) = self
1735 .lookup_file_data(file)
1736 .map(|data| get_symbol_meta_from_data(data, symbol))
1737 .unwrap_or_else(|| get_symbol_meta(file, symbol));
1738
1739 TraceToSymbolHop {
1740 symbol: symbol.to_string(),
1741 file: self.relative_path(file),
1742 line,
1743 }
1744 }
1745
1746 fn forward_resolved_callees(
1747 &mut self,
1748 file: &Path,
1749 symbol: &str,
1750 ) -> Result<Vec<(PathBuf, String)>, AftError> {
1751 let canon = self.canonicalize(file)?;
1752 let (import_block, call_sites) = {
1753 let file_data = self.build_file(&canon)?;
1754 (
1755 file_data.import_block.clone(),
1756 file_data
1757 .calls_by_symbol
1758 .get(symbol)
1759 .cloned()
1760 .unwrap_or_default(),
1761 )
1762 };
1763
1764 let mut callees = Vec::new();
1765 for call_site in call_sites {
1766 let edge = self.resolve_cross_file_edge(
1767 &call_site.full_callee,
1768 &call_site.callee_name,
1769 &canon,
1770 &import_block,
1771 );
1772
1773 match edge {
1774 EdgeResolution::Resolved {
1775 file: target_file,
1776 symbol: target_symbol,
1777 } => {
1778 let target_canon = self.canonicalize(&target_file)?;
1779 if self.build_file(&target_canon).is_err() {
1780 continue;
1781 }
1782
1783 let resolved_target_symbol = self
1784 .lookup_file_data(&target_canon)
1785 .and_then(|data| {
1786 resolve_symbol_query_in_data(data, &target_canon, &target_symbol).ok()
1787 })
1788 .unwrap_or(target_symbol);
1789
1790 callees.push((target_canon, resolved_target_symbol));
1791 }
1792 EdgeResolution::Unresolved { callee_name } => {
1793 if !is_bare_callee(&call_site.full_callee, &callee_name) {
1794 continue;
1795 }
1796
1797 let local_symbol = self.lookup_file_data(&canon).and_then(|data| {
1798 resolve_symbol_query_in_data(data, &canon, &callee_name).ok()
1799 });
1800
1801 if let Some(local_symbol) = local_symbol {
1802 callees.push((canon.clone(), local_symbol));
1803 }
1804 }
1805 }
1806 }
1807
1808 Ok(callees)
1809 }
1810
1811 pub fn impact(
1817 &mut self,
1818 file: &Path,
1819 symbol: &str,
1820 depth: usize,
1821 max_files: usize,
1822 ) -> Result<ImpactResult, AftError> {
1823 let canon = self.canonicalize(file)?;
1824
1825 let resolved_symbol = {
1827 let file_data = self.build_file(&canon)?;
1828 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1829 };
1830
1831 if self.reverse_index.is_none() {
1833 self.build_reverse_index(max_files)?;
1834 }
1835
1836 let effective_depth = if depth == 0 { 1 } else { depth };
1837
1838 let (target_signature, target_parameters, target_lang) = {
1840 let file_data = match self.data.get(&canon) {
1841 Some(d) => d,
1842 None => {
1843 return Err(AftError::InvalidRequest {
1844 message: "file data missing after build".to_string(),
1845 })
1846 }
1847 };
1848 let meta = file_data.symbol_metadata.get(&resolved_symbol);
1849 let sig = meta.and_then(|m| m.signature.clone());
1850 let lang = file_data.lang;
1851 let params = sig
1852 .as_deref()
1853 .map(|s| extract_parameters(s, lang))
1854 .unwrap_or_default();
1855 (sig, params, lang)
1856 };
1857
1858 let mut visited = HashSet::new();
1860 let mut all_sites: Vec<CallerSite> = Vec::new();
1861 let mut depth_limited = false;
1862 let mut truncated = 0;
1863 self.collect_callers_recursive(
1864 &canon,
1865 &resolved_symbol,
1866 effective_depth,
1867 0,
1868 &mut visited,
1869 &mut all_sites,
1870 &mut depth_limited,
1871 &mut truncated,
1872 );
1873
1874 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1876 all_sites.retain(|site| {
1877 seen.insert((
1878 site.caller_file.clone(),
1879 site.caller_symbol.clone(),
1880 site.line,
1881 ))
1882 });
1883
1884 let mut callers = Vec::new();
1886 let mut affected_file_set = HashSet::new();
1887
1888 for site in &all_sites {
1889 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1891 log::debug!(
1892 "callgraph: skipping caller file {}: {}",
1893 site.caller_file.display(),
1894 e
1895 );
1896 }
1897
1898 let (sig, is_ep, params, _lang) = {
1899 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1900 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1901 let sig = meta.and_then(|m| m.signature.clone());
1902 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1903 let exported = meta.map(|m| m.exported).unwrap_or(false);
1904 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1905 let lang = fd.lang;
1906 let params = sig
1907 .as_deref()
1908 .map(|s| extract_parameters(s, lang))
1909 .unwrap_or_default();
1910 (sig, is_ep, params, lang)
1911 } else {
1912 (None, false, Vec::new(), target_lang)
1913 }
1914 };
1915
1916 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1918
1919 let rel_file = self.relative_path(site.caller_file.as_path());
1920 affected_file_set.insert(rel_file.clone());
1921
1922 callers.push(ImpactCaller {
1923 caller_symbol: site.caller_symbol.clone(),
1924 caller_file: rel_file,
1925 line: site.line,
1926 signature: sig,
1927 is_entry_point: is_ep,
1928 call_expression,
1929 parameters: params,
1930 });
1931 }
1932
1933 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1935
1936 let total_affected = callers.len();
1937 let affected_files = affected_file_set.len();
1938
1939 Ok(ImpactResult {
1940 symbol: resolved_symbol,
1941 file: self.relative_path(&canon),
1942 signature: target_signature,
1943 parameters: target_parameters,
1944 total_affected,
1945 affected_files,
1946 callers,
1947 depth_limited,
1948 truncated,
1949 })
1950 }
1951
1952 pub fn trace_data(
1963 &mut self,
1964 file: &Path,
1965 symbol: &str,
1966 expression: &str,
1967 max_depth: usize,
1968 max_files: usize,
1969 ) -> Result<TraceDataResult, AftError> {
1970 let canon = self.canonicalize(file)?;
1971 let rel_file = self.relative_path(&canon);
1972
1973 let resolved_symbol = {
1975 let file_data = self.build_file(&canon)?;
1976 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1977 };
1978
1979 let count = self.project_file_count_bounded(max_files);
1983 if count > max_files {
1984 return Err(AftError::ProjectTooLarge {
1985 count,
1986 max: max_files,
1987 });
1988 }
1989
1990 let mut hops = Vec::new();
1991 let mut depth_limited = false;
1992
1993 self.trace_data_inner(
1994 &canon,
1995 &resolved_symbol,
1996 expression,
1997 max_depth,
1998 0,
1999 &mut hops,
2000 &mut depth_limited,
2001 &mut HashSet::new(),
2002 );
2003
2004 Ok(TraceDataResult {
2005 expression: expression.to_string(),
2006 origin_file: rel_file,
2007 origin_symbol: resolved_symbol,
2008 hops,
2009 depth_limited,
2010 })
2011 }
2012
2013 fn trace_data_inner(
2015 &mut self,
2016 file: &Path,
2017 symbol: &str,
2018 tracking_name: &str,
2019 max_depth: usize,
2020 current_depth: usize,
2021 hops: &mut Vec<DataFlowHop>,
2022 depth_limited: &mut bool,
2023 visited: &mut HashSet<(PathBuf, String, String)>,
2024 ) {
2025 let visit_key = (
2026 file.to_path_buf(),
2027 symbol.to_string(),
2028 tracking_name.to_string(),
2029 );
2030 if visited.contains(&visit_key) {
2031 return; }
2033 visited.insert(visit_key);
2034
2035 let source = match std::fs::read_to_string(file) {
2037 Ok(s) => s,
2038 Err(_) => return,
2039 };
2040
2041 let lang = match detect_language(file) {
2042 Some(l) => l,
2043 None => return,
2044 };
2045
2046 let grammar = grammar_for(lang);
2047 let mut parser = Parser::new();
2048 if parser.set_language(&grammar).is_err() {
2049 return;
2050 }
2051 let tree = match parser.parse(&source, None) {
2052 Some(t) => t,
2053 None => return,
2054 };
2055
2056 let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
2058 Ok(symbols) => symbols,
2059 Err(_) => return,
2060 };
2061 let sym_info = match symbols
2062 .iter()
2063 .find(|s| symbol_identity(s) == symbol || s.name == symbol)
2064 {
2065 Some(s) => s,
2066 None => return,
2067 };
2068
2069 let body_start =
2070 line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
2071 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
2072
2073 let root = tree.root_node();
2074
2075 let body_node = match find_node_covering_range(root, body_start, body_end) {
2077 Some(n) => n,
2078 None => return,
2079 };
2080
2081 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
2083 let rel_file = self.relative_path(file);
2084
2085 self.walk_for_data_flow(
2087 body_node,
2088 &source,
2089 &mut tracked_names,
2090 file,
2091 symbol,
2092 &rel_file,
2093 lang,
2094 max_depth,
2095 current_depth,
2096 hops,
2097 depth_limited,
2098 visited,
2099 );
2100 }
2101
2102 #[allow(clippy::too_many_arguments)]
2105 fn walk_for_data_flow(
2106 &mut self,
2107 node: tree_sitter::Node,
2108 source: &str,
2109 tracked_names: &mut Vec<String>,
2110 file: &Path,
2111 symbol: &str,
2112 rel_file: &str,
2113 lang: LangId,
2114 max_depth: usize,
2115 current_depth: usize,
2116 hops: &mut Vec<DataFlowHop>,
2117 depth_limited: &mut bool,
2118 visited: &mut HashSet<(PathBuf, String, String)>,
2119 ) {
2120 let kind = node.kind();
2121
2122 let is_var_decl = matches!(
2124 kind,
2125 "variable_declarator"
2126 | "assignment_expression"
2127 | "augmented_assignment_expression"
2128 | "assignment"
2129 | "let_declaration"
2130 | "short_var_declaration"
2131 );
2132
2133 if is_var_decl {
2134 if let Some((new_name, init_text, line, is_approx)) =
2135 self.extract_assignment_info(node, source, lang, tracked_names)
2136 {
2137 if !is_approx {
2139 hops.push(DataFlowHop {
2140 file: rel_file.to_string(),
2141 symbol: symbol.to_string(),
2142 variable: new_name.clone(),
2143 line,
2144 flow_type: "assignment".to_string(),
2145 approximate: false,
2146 });
2147 tracked_names.push(new_name);
2148 } else {
2149 hops.push(DataFlowHop {
2151 file: rel_file.to_string(),
2152 symbol: symbol.to_string(),
2153 variable: init_text,
2154 line,
2155 flow_type: "assignment".to_string(),
2156 approximate: true,
2157 });
2158 return;
2160 }
2161 }
2162 }
2163
2164 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
2166 self.check_call_for_data_flow(
2167 node,
2168 source,
2169 tracked_names,
2170 file,
2171 symbol,
2172 rel_file,
2173 lang,
2174 max_depth,
2175 current_depth,
2176 hops,
2177 depth_limited,
2178 visited,
2179 );
2180 }
2181
2182 let mut cursor = node.walk();
2184 if cursor.goto_first_child() {
2185 loop {
2186 let child = cursor.node();
2187 self.walk_for_data_flow(
2189 child,
2190 source,
2191 tracked_names,
2192 file,
2193 symbol,
2194 rel_file,
2195 lang,
2196 max_depth,
2197 current_depth,
2198 hops,
2199 depth_limited,
2200 visited,
2201 );
2202 if !cursor.goto_next_sibling() {
2203 break;
2204 }
2205 }
2206 }
2207 }
2208
2209 fn extract_assignment_info(
2212 &self,
2213 node: tree_sitter::Node,
2214 source: &str,
2215 _lang: LangId,
2216 tracked_names: &[String],
2217 ) -> Option<(String, String, u32, bool)> {
2218 let kind = node.kind();
2219 let line = node.start_position().row as u32 + 1;
2220
2221 match kind {
2222 "variable_declarator" => {
2223 let name_node = node.child_by_field_name("name")?;
2225 let value_node = node.child_by_field_name("value")?;
2226 let name_text = node_text(name_node, source);
2227 let value_text = node_text(value_node, source);
2228
2229 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
2231 if tracked_names.iter().any(|t| value_text.contains(t)) {
2233 return Some((name_text.clone(), name_text, line, true));
2234 }
2235 return None;
2236 }
2237
2238 if tracked_names.iter().any(|t| {
2240 value_text == *t
2241 || value_text.starts_with(&format!("{}.", t))
2242 || value_text.starts_with(&format!("{}[", t))
2243 }) {
2244 return Some((name_text, value_text, line, false));
2245 }
2246 None
2247 }
2248 "assignment_expression" | "augmented_assignment_expression" => {
2249 let left = node.child_by_field_name("left")?;
2251 let right = node.child_by_field_name("right")?;
2252 let left_text = node_text(left, source);
2253 let right_text = node_text(right, source);
2254
2255 if tracked_names.iter().any(|t| right_text == *t) {
2256 return Some((left_text, right_text, line, false));
2257 }
2258 None
2259 }
2260 "assignment" => {
2261 let left = node.child_by_field_name("left")?;
2263 let right = node.child_by_field_name("right")?;
2264 let left_text = node_text(left, source);
2265 let right_text = node_text(right, source);
2266
2267 if tracked_names.iter().any(|t| right_text == *t) {
2268 return Some((left_text, right_text, line, false));
2269 }
2270 None
2271 }
2272 "let_declaration" | "short_var_declaration" => {
2273 let left = node
2275 .child_by_field_name("pattern")
2276 .or_else(|| node.child_by_field_name("left"))?;
2277 let right = node
2278 .child_by_field_name("value")
2279 .or_else(|| node.child_by_field_name("right"))?;
2280 let left_text = node_text(left, source);
2281 let right_text = node_text(right, source);
2282
2283 if tracked_names.iter().any(|t| right_text == *t) {
2284 return Some((left_text, right_text, line, false));
2285 }
2286 None
2287 }
2288 _ => None,
2289 }
2290 }
2291
2292 #[allow(clippy::too_many_arguments)]
2295 fn check_call_for_data_flow(
2296 &mut self,
2297 node: tree_sitter::Node,
2298 source: &str,
2299 tracked_names: &[String],
2300 file: &Path,
2301 _symbol: &str,
2302 rel_file: &str,
2303 _lang: LangId,
2304 max_depth: usize,
2305 current_depth: usize,
2306 hops: &mut Vec<DataFlowHop>,
2307 depth_limited: &mut bool,
2308 visited: &mut HashSet<(PathBuf, String, String)>,
2309 ) {
2310 let args_node = find_child_by_kind(node, "arguments")
2312 .or_else(|| find_child_by_kind(node, "argument_list"));
2313
2314 let args_node = match args_node {
2315 Some(n) => n,
2316 None => return,
2317 };
2318
2319 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
2322
2323 let mut cursor = args_node.walk();
2324 if cursor.goto_first_child() {
2325 loop {
2326 let child = cursor.node();
2327 let child_kind = child.kind();
2328
2329 if child_kind == "(" || child_kind == ")" || child_kind == "," {
2331 if !cursor.goto_next_sibling() {
2332 break;
2333 }
2334 continue;
2335 }
2336
2337 let arg_text = node_text(child, source);
2338
2339 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
2341 if tracked_names.iter().any(|t| arg_text.contains(t)) {
2342 hops.push(DataFlowHop {
2343 file: rel_file.to_string(),
2344 symbol: _symbol.to_string(),
2345 variable: arg_text,
2346 line: child.start_position().row as u32 + 1,
2347 flow_type: "parameter".to_string(),
2348 approximate: true,
2349 });
2350 }
2351 if !cursor.goto_next_sibling() {
2352 break;
2353 }
2354 arg_idx += 1;
2355 continue;
2356 }
2357
2358 if tracked_names.iter().any(|t| arg_text == *t) {
2359 arg_positions.push((arg_idx, arg_text));
2360 }
2361
2362 arg_idx += 1;
2363 if !cursor.goto_next_sibling() {
2364 break;
2365 }
2366 }
2367 }
2368
2369 if arg_positions.is_empty() {
2370 return;
2371 }
2372
2373 let (full_callee, short_callee) = extract_callee_names(node, source);
2375 let full_callee = match full_callee {
2376 Some(f) => f,
2377 None => return,
2378 };
2379 let short_callee = match short_callee {
2380 Some(s) => s,
2381 None => return,
2382 };
2383
2384 let import_block = {
2386 match self.data.get(file) {
2387 Some(fd) => fd.import_block.clone(),
2388 None => return,
2389 }
2390 };
2391
2392 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
2393
2394 match edge {
2395 EdgeResolution::Resolved {
2396 file: target_file,
2397 symbol: target_symbol,
2398 } => {
2399 if current_depth + 1 > max_depth {
2400 *depth_limited = true;
2401 return;
2402 }
2403
2404 if let Err(e) = self.build_file(&target_file) {
2406 log::debug!(
2407 "callgraph: skipping target file {}: {}",
2408 target_file.display(),
2409 e
2410 );
2411 }
2412 let (params, target_line) = {
2413 match self.lookup_file_data(&target_file) {
2414 Some(fd) => {
2415 let meta = fd.symbol_metadata.get(&target_symbol);
2416 let sig = meta.and_then(|m| m.signature.clone());
2417 let params = sig
2418 .as_deref()
2419 .map(|s| extract_parameters(s, fd.lang))
2420 .unwrap_or_default();
2421 let line = meta.map(|m| m.line).unwrap_or(1);
2422 (params, line)
2423 }
2424 None => return,
2425 }
2426 };
2427
2428 let target_rel = self.relative_path(&target_file);
2429
2430 for (pos, _tracked) in &arg_positions {
2431 if let Some(param_name) = params.get(*pos) {
2432 hops.push(DataFlowHop {
2434 file: target_rel.clone(),
2435 symbol: target_symbol.clone(),
2436 variable: param_name.clone(),
2437 line: target_line,
2438 flow_type: "parameter".to_string(),
2439 approximate: false,
2440 });
2441
2442 self.trace_data_inner(
2444 &target_file.clone(),
2445 &target_symbol.clone(),
2446 param_name,
2447 max_depth,
2448 current_depth + 1,
2449 hops,
2450 depth_limited,
2451 visited,
2452 );
2453 }
2454 }
2455 }
2456 EdgeResolution::Unresolved { callee_name } => {
2457 let local_symbol = if is_bare_callee(&full_callee, &callee_name) {
2458 self.data
2459 .get(file)
2460 .and_then(|fd| resolve_symbol_query_in_data(fd, file, &callee_name).ok())
2461 } else {
2462 None
2463 };
2464
2465 if let Some(local_symbol) = local_symbol {
2466 let (params, target_line) = {
2468 let Some(fd) = self.data.get(file) else {
2469 return;
2470 };
2471 let meta = fd.symbol_metadata.get(&local_symbol);
2472 let sig = meta.and_then(|m| m.signature.clone());
2473 let params = sig
2474 .as_deref()
2475 .map(|s| extract_parameters(s, fd.lang))
2476 .unwrap_or_default();
2477 let line = meta.map(|m| m.line).unwrap_or(1);
2478 (params, line)
2479 };
2480
2481 let file_rel = self.relative_path(file);
2482
2483 for (pos, _tracked) in &arg_positions {
2484 if let Some(param_name) = params.get(*pos) {
2485 hops.push(DataFlowHop {
2486 file: file_rel.clone(),
2487 symbol: local_symbol.clone(),
2488 variable: param_name.clone(),
2489 line: target_line,
2490 flow_type: "parameter".to_string(),
2491 approximate: false,
2492 });
2493
2494 self.trace_data_inner(
2496 file,
2497 &local_symbol,
2498 param_name,
2499 max_depth,
2500 current_depth + 1,
2501 hops,
2502 depth_limited,
2503 visited,
2504 );
2505 }
2506 }
2507 } else {
2508 for (_pos, tracked) in &arg_positions {
2510 hops.push(DataFlowHop {
2511 file: self.relative_path(file),
2512 symbol: callee_name.clone(),
2513 variable: tracked.clone(),
2514 line: node.start_position().row as u32 + 1,
2515 flow_type: "parameter".to_string(),
2516 approximate: true,
2517 });
2518 }
2519 }
2520 }
2521 }
2522 }
2523
2524 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2526 let content = std::fs::read_to_string(path).ok()?;
2527 content
2528 .lines()
2529 .nth(line.saturating_sub(1) as usize)
2530 .map(|l| l.trim().to_string())
2531 }
2532
2533 fn collect_callers_recursive(
2535 &self,
2536 file: &Path,
2537 symbol: &str,
2538 max_depth: usize,
2539 current_depth: usize,
2540 visited: &mut HashSet<(PathBuf, SharedStr)>,
2541 result: &mut Vec<CallerSite>,
2542 depth_limited: &mut bool,
2543 truncated: &mut usize,
2544 ) {
2545 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2547 let key_symbol: SharedStr = Arc::from(symbol);
2548
2549 if current_depth >= max_depth {
2550 let omitted = self
2551 .reverse_sites(&canon, key_symbol.as_ref())
2552 .map(|sites| sites.len())
2553 .unwrap_or(0);
2554 if omitted > 0 {
2555 *depth_limited = true;
2556 *truncated += omitted;
2557 }
2558 return;
2559 }
2560
2561 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2562 return; }
2564
2565 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2566 for site in sites {
2567 result.push(CallerSite {
2568 caller_file: site.caller_file.as_ref().clone(),
2569 caller_symbol: site.caller_symbol.to_string(),
2570 line: site.line,
2571 col: site.col,
2572 resolved: site.resolved,
2573 });
2574 if current_depth + 1 < max_depth {
2576 self.collect_callers_recursive(
2577 site.caller_file.as_ref(),
2578 site.caller_symbol.as_ref(),
2579 max_depth,
2580 current_depth + 1,
2581 visited,
2582 result,
2583 depth_limited,
2584 truncated,
2585 );
2586 } else {
2587 let omitted = self
2588 .reverse_sites(site.caller_file.as_ref(), site.caller_symbol.as_ref())
2589 .map(|sites| sites.len())
2590 .unwrap_or(0);
2591 if omitted > 0 {
2592 *depth_limited = true;
2593 *truncated += omitted;
2594 }
2595 }
2596 }
2597 }
2598 }
2599
2600 pub fn invalidate_file(&mut self, path: &Path) {
2605 self.data.remove(path);
2607 if let Ok(canon) = self.canonicalize(path) {
2608 self.data.remove(&canon);
2609 }
2610 self.reverse_index = None;
2612 self.project_files = None;
2614 clear_workspace_package_cache();
2615 }
2616
2617 fn relative_path(&self, path: &Path) -> String {
2620 path.strip_prefix(&self.project_root)
2621 .unwrap_or(path)
2622 .display()
2623 .to_string()
2624 }
2625
2626 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2628 let full_path = if path.is_relative() {
2630 self.project_root.join(path)
2631 } else {
2632 path.to_path_buf()
2633 };
2634
2635 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2637 }
2638
2639 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2643 if let Some(fd) = self.data.get(path) {
2644 return Some(fd);
2645 }
2646 let canon = std::fs::canonicalize(path).ok()?;
2648 self.data.get(&canon).or_else(|| {
2649 self.data.iter().find_map(|(k, v)| {
2651 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2652 Some(v)
2653 } else {
2654 None
2655 }
2656 })
2657 })
2658 }
2659}
2660
2661fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2667 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2668 message: format!("unsupported file for call graph: {}", path.display()),
2669 })?;
2670
2671 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2672 path: format!("{}: {}", path.display(), e),
2673 })?;
2674
2675 let grammar = grammar_for(lang);
2676 let mut parser = Parser::new();
2677 parser
2678 .set_language(&grammar)
2679 .map_err(|e| AftError::ParseError {
2680 message: format!("grammar init failed for {:?}: {}", lang, e),
2681 })?;
2682
2683 let tree = parser
2684 .parse(&source, None)
2685 .ok_or_else(|| AftError::ParseError {
2686 message: format!("parse failed for {}", path.display()),
2687 })?;
2688
2689 let import_block = imports::parse_imports(&source, &tree, lang);
2691
2692 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2694
2695 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2697 let root = tree.root_node();
2698
2699 for sym in &symbols {
2700 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2701 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2702
2703 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2704
2705 let sites: Vec<CallSite> = raw_calls
2706 .into_iter()
2707 .map(|(full, short, line)| CallSite {
2708 callee_name: short,
2709 full_callee: full,
2710 line,
2711 byte_start,
2712 byte_end,
2713 })
2714 .collect();
2715
2716 if !sites.is_empty() {
2717 calls_by_symbol.insert(symbol_identity(sym), sites);
2718 }
2719 }
2720
2721 let symbol_ranges: Vec<(usize, usize)> = symbols
2722 .iter()
2723 .map(|sym| {
2724 (
2725 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2726 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2727 )
2728 })
2729 .collect();
2730
2731 let top_level_sites: Vec<CallSite> =
2732 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2733 .into_iter()
2734 .filter(|site| {
2735 !symbol_ranges
2736 .iter()
2737 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2738 })
2739 .map(|site| CallSite {
2740 callee_name: site.short,
2741 full_callee: site.full,
2742 line: site.line,
2743 byte_start: site.byte_start,
2744 byte_end: site.byte_end,
2745 })
2746 .collect();
2747
2748 if !top_level_sites.is_empty() {
2749 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2750 }
2751
2752 let default_export = find_default_export(&source, root, path, lang);
2753
2754 if let Some(default_export) = &default_export {
2755 if default_export.synthetic {
2756 let byte_start = default_export.node.byte_range().start;
2757 let byte_end = default_export.node.byte_range().end;
2758 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2759 let sites: Vec<CallSite> = raw_calls
2760 .into_iter()
2761 .filter(|(_, short, _)| *short != default_export.symbol)
2762 .map(|(full, short, line)| CallSite {
2763 callee_name: short,
2764 full_callee: full,
2765 line,
2766 byte_start,
2767 byte_end,
2768 })
2769 .collect();
2770 if !sites.is_empty() {
2771 calls_by_symbol.insert(default_export.symbol.clone(), sites);
2772 }
2773 }
2774 }
2775
2776 let mut exported_symbols: Vec<String> = symbols
2778 .iter()
2779 .filter(|s| s.exported)
2780 .map(|s| s.name.clone())
2781 .collect();
2782 if let Some(default_export) = &default_export {
2783 if !exported_symbols
2784 .iter()
2785 .any(|name| name == &default_export.symbol)
2786 {
2787 exported_symbols.push(default_export.symbol.clone());
2788 }
2789 }
2790
2791 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2793 .iter()
2794 .map(|s| {
2795 (
2796 symbol_identity(s),
2797 SymbolMeta {
2798 kind: s.kind.clone(),
2799 exported: s.exported,
2800 signature: s.signature.clone(),
2801 line: s.range.start_line + 1,
2802 range: s.range.clone(),
2803 },
2804 )
2805 })
2806 .collect();
2807 if let Some(default_export) = &default_export {
2808 symbol_metadata
2809 .entry(default_export.symbol.clone())
2810 .or_insert_with(|| SymbolMeta {
2811 kind: default_export.kind.clone(),
2812 exported: true,
2813 signature: Some(first_line_signature(&source, &default_export.node)),
2814 line: default_export.node.start_position().row as u32 + 1,
2815 range: crate::parser::node_range(&default_export.node),
2816 });
2817 }
2818 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2819 symbol_metadata
2820 .entry(TOP_LEVEL_SYMBOL.to_string())
2821 .or_insert(SymbolMeta {
2822 kind: SymbolKind::Function,
2823 exported: false,
2824 signature: None,
2825 line: 1,
2826 range: Range {
2827 start_line: 0,
2828 start_col: 0,
2829 end_line: 0,
2830 end_col: 0,
2831 },
2832 });
2833 }
2834
2835 Ok(FileCallData {
2836 calls_by_symbol,
2837 exported_symbols,
2838 symbol_metadata,
2839 default_export_symbol: default_export.map(|export| export.symbol),
2840 import_block,
2841 lang,
2842 })
2843}
2844
2845#[derive(Debug, Clone)]
2846struct DefaultExport<'tree> {
2847 symbol: String,
2848 synthetic: bool,
2849 kind: SymbolKind,
2850 node: Node<'tree>,
2851}
2852
2853fn find_default_export<'tree>(
2854 source: &str,
2855 root: Node<'tree>,
2856 path: &Path,
2857 lang: LangId,
2858) -> Option<DefaultExport<'tree>> {
2859 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2860 return None;
2861 }
2862 find_default_export_inner(source, root, path)
2863}
2864
2865fn find_default_export_inner<'tree>(
2866 source: &str,
2867 node: Node<'tree>,
2868 path: &Path,
2869) -> Option<DefaultExport<'tree>> {
2870 if node.kind() == "export_statement" {
2871 if let Some(default_export) = default_export_from_statement(source, node, path) {
2872 return Some(default_export);
2873 }
2874 }
2875
2876 let mut cursor = node.walk();
2877 if !cursor.goto_first_child() {
2878 return None;
2879 }
2880
2881 loop {
2882 let child = cursor.node();
2883 if let Some(default_export) = find_default_export_inner(source, child, path) {
2884 return Some(default_export);
2885 }
2886 if !cursor.goto_next_sibling() {
2887 break;
2888 }
2889 }
2890
2891 None
2892}
2893
2894fn default_export_from_statement<'tree>(
2895 source: &str,
2896 node: Node<'tree>,
2897 path: &Path,
2898) -> Option<DefaultExport<'tree>> {
2899 let mut cursor = node.walk();
2900 if !cursor.goto_first_child() {
2901 return None;
2902 }
2903
2904 let mut saw_default = false;
2905 loop {
2906 let child = cursor.node();
2907 match child.kind() {
2908 "default" => saw_default = true,
2909 "function_declaration" | "generator_function_declaration" | "class_declaration"
2910 if saw_default =>
2911 {
2912 if let Some(name_node) = child.child_by_field_name("name") {
2913 return Some(DefaultExport {
2914 symbol: source[name_node.byte_range()].to_string(),
2915 synthetic: false,
2916 kind: default_export_kind(&child),
2917 node: child,
2918 });
2919 }
2920 return Some(DefaultExport {
2921 symbol: synthetic_default_symbol(path),
2922 synthetic: true,
2923 kind: default_export_kind(&child),
2924 node: child,
2925 });
2926 }
2927 "arrow_function"
2928 | "function"
2929 | "function_expression"
2930 | "class"
2931 | "class_expression"
2932 if saw_default =>
2933 {
2934 return Some(DefaultExport {
2935 symbol: synthetic_default_symbol(path),
2936 synthetic: true,
2937 kind: default_export_kind(&child),
2938 node: child,
2939 });
2940 }
2941 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
2942 return Some(DefaultExport {
2943 symbol: source[child.byte_range()].to_string(),
2944 synthetic: false,
2945 kind: SymbolKind::Function,
2946 node: child,
2947 });
2948 }
2949 _ => {}
2950 }
2951 if !cursor.goto_next_sibling() {
2952 break;
2953 }
2954 }
2955
2956 None
2957}
2958
2959fn default_export_kind(node: &Node) -> SymbolKind {
2960 if node.kind().contains("class") {
2961 SymbolKind::Class
2962 } else {
2963 SymbolKind::Function
2964 }
2965}
2966
2967fn synthetic_default_symbol(path: &Path) -> String {
2968 let file_name = path
2969 .file_name()
2970 .and_then(|name| name.to_str())
2971 .unwrap_or("unknown");
2972 format!("<default:{file_name}>")
2973}
2974
2975fn first_line_signature(source: &str, node: &Node) -> String {
2976 let text = &source[node.byte_range()];
2977 let first_line = text.lines().next().unwrap_or(text);
2978 first_line
2979 .trim_end()
2980 .trim_end_matches('{')
2981 .trim_end()
2982 .to_string()
2983}
2984
2985fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2986 file_data
2987 .symbol_metadata
2988 .get(symbol_name)
2989 .map(|meta| (meta.line, meta.signature.clone()))
2990 .unwrap_or((1, None))
2991}
2992
2993fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2995 let provider = crate::parser::TreeSitterProvider::new();
2996 match provider.list_symbols(path) {
2997 Ok(symbols) => {
2998 for s in &symbols {
2999 if symbol_identity(s) == symbol_name || s.name == symbol_name {
3000 return (s.range.start_line + 1, s.signature.clone());
3001 }
3002 }
3003 (1, None)
3004 }
3005 Err(_) => (1, None),
3006 }
3007}
3008
3009fn node_text(node: tree_sitter::Node, source: &str) -> String {
3015 source[node.start_byte()..node.end_byte()].to_string()
3016}
3017
3018fn find_node_covering_range(
3020 root: tree_sitter::Node,
3021 start: usize,
3022 end: usize,
3023) -> Option<tree_sitter::Node> {
3024 let mut best = None;
3025 let mut cursor = root.walk();
3026
3027 fn walk_covering<'a>(
3028 cursor: &mut tree_sitter::TreeCursor<'a>,
3029 start: usize,
3030 end: usize,
3031 best: &mut Option<tree_sitter::Node<'a>>,
3032 ) {
3033 let node = cursor.node();
3034 if node.start_byte() <= start && node.end_byte() >= end {
3035 *best = Some(node);
3036 if cursor.goto_first_child() {
3037 loop {
3038 walk_covering(cursor, start, end, best);
3039 if !cursor.goto_next_sibling() {
3040 break;
3041 }
3042 }
3043 cursor.goto_parent();
3044 }
3045 }
3046 }
3047
3048 walk_covering(&mut cursor, start, end, &mut best);
3049 best
3050}
3051
3052fn find_child_by_kind<'a>(
3054 node: tree_sitter::Node<'a>,
3055 kind: &str,
3056) -> Option<tree_sitter::Node<'a>> {
3057 let mut cursor = node.walk();
3058 if cursor.goto_first_child() {
3059 loop {
3060 if cursor.node().kind() == kind {
3061 return Some(cursor.node());
3062 }
3063 if !cursor.goto_next_sibling() {
3064 break;
3065 }
3066 }
3067 }
3068 None
3069}
3070
3071#[derive(Debug, Clone)]
3072struct CallSiteWithRange {
3073 full: String,
3074 short: String,
3075 line: u32,
3076 byte_start: usize,
3077 byte_end: usize,
3078}
3079
3080fn collect_calls_full_with_ranges(
3081 root: tree_sitter::Node,
3082 source: &str,
3083 byte_start: usize,
3084 byte_end: usize,
3085 lang: LangId,
3086) -> Vec<CallSiteWithRange> {
3087 let mut results = Vec::new();
3088 let call_kinds = call_node_kinds(lang);
3089 collect_calls_full_with_ranges_inner(
3090 root,
3091 source,
3092 byte_start,
3093 byte_end,
3094 &call_kinds,
3095 &mut results,
3096 );
3097 results
3098}
3099
3100fn collect_calls_full_with_ranges_inner(
3101 node: tree_sitter::Node,
3102 source: &str,
3103 byte_start: usize,
3104 byte_end: usize,
3105 call_kinds: &[&str],
3106 results: &mut Vec<CallSiteWithRange>,
3107) {
3108 let node_start = node.start_byte();
3109 let node_end = node.end_byte();
3110
3111 if node_end <= byte_start || node_start >= byte_end {
3112 return;
3113 }
3114
3115 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
3116 if let (Some(full), Some(short)) = (
3117 extract_full_callee(&node, source),
3118 extract_callee_name(&node, source),
3119 ) {
3120 results.push(CallSiteWithRange {
3121 full,
3122 short,
3123 line: node.start_position().row as u32 + 1,
3124 byte_start: node_start,
3125 byte_end: node_end,
3126 });
3127 }
3128 }
3129
3130 let mut cursor = node.walk();
3131 if cursor.goto_first_child() {
3132 loop {
3133 collect_calls_full_with_ranges_inner(
3134 cursor.node(),
3135 source,
3136 byte_start,
3137 byte_end,
3138 call_kinds,
3139 results,
3140 );
3141 if !cursor.goto_next_sibling() {
3142 break;
3143 }
3144 }
3145 }
3146}
3147
3148fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
3150 let callee = match node.child_by_field_name("function") {
3152 Some(c) => c,
3153 None => return (None, None),
3154 };
3155
3156 let full = node_text(callee, source);
3157 let short = if full.contains('.') {
3158 full.rsplit('.').next().unwrap_or(&full).to_string()
3159 } else {
3160 full.clone()
3161 };
3162
3163 (Some(full), Some(short))
3164}
3165
3166pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3174 if module_path.starts_with('.') {
3175 return resolve_relative_module_path(from_dir, module_path);
3176 }
3177
3178 if module_path.starts_with('/') {
3179 return None;
3180 }
3181
3182 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
3183 return Some(path);
3184 }
3185
3186 resolve_workspace_module_path(from_dir, module_path)
3187}
3188
3189fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3190 let base = from_dir.join(module_path);
3191 resolve_file_like_path(&base)
3192}
3193
3194fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
3195 let base = base.to_path_buf();
3196
3197 if base.is_file() {
3199 return Some(std::fs::canonicalize(&base).unwrap_or(base));
3200 }
3201
3202 for ext in JS_TS_EXTENSIONS {
3204 let with_ext = base.with_extension(ext);
3205 if with_ext.is_file() {
3206 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
3207 }
3208 }
3209
3210 if base.is_dir() {
3212 if let Some(index) = find_index_file(&base) {
3213 return Some(index);
3214 }
3215 }
3216
3217 None
3218}
3219
3220fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3221 let (package_name, subpath) = split_package_import(module_path)?;
3222 let package_root = find_package_root_for_import(from_dir, &package_name)?;
3223 resolve_package_entry(&package_root, &subpath)
3224}
3225
3226fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3227 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
3228 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
3229 let compiler_options = tsconfig.get("compilerOptions")?;
3230 let paths = compiler_options.get("paths")?.as_object()?;
3231 let base_url = compiler_options
3232 .get("baseUrl")
3233 .and_then(Value::as_str)
3234 .unwrap_or(".");
3235 let base_dir = tsconfig_dir.join(base_url);
3236
3237 for (alias, targets) in paths {
3238 let Some(capture) = ts_path_capture(alias, module_path) else {
3239 continue;
3240 };
3241 let Some(targets) = targets.as_array() else {
3242 continue;
3243 };
3244 for target in targets.iter().filter_map(Value::as_str) {
3245 let target = if target.contains('*') {
3246 target.replace('*', capture)
3247 } else {
3248 target.to_string()
3249 };
3250 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
3251 return Some(path);
3252 }
3253 }
3254 }
3255
3256 None
3257}
3258
3259fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
3260 let mut current = Some(from_dir);
3261 while let Some(dir) = current {
3262 if dir.join("tsconfig.json").is_file() {
3263 return Some(dir.to_path_buf());
3264 }
3265 current = dir.parent();
3266 }
3267 None
3268}
3269
3270fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
3271 if let Some(star_index) = alias.find('*') {
3272 let (prefix, suffix_with_star) = alias.split_at(star_index);
3273 let suffix = &suffix_with_star[1..];
3274 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
3275 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
3276 }
3277 return None;
3278 }
3279
3280 (alias == module_path).then_some("")
3281}
3282
3283fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
3284 let mut parts = module_path.split('/');
3285 let first = parts.next()?;
3286 if first.is_empty() {
3287 return None;
3288 }
3289
3290 if first.starts_with('@') {
3291 let second = parts.next()?;
3292 if second.is_empty() {
3293 return None;
3294 }
3295 let package_name = format!("{first}/{second}");
3296 let subpath = parts.collect::<Vec<_>>().join("/");
3297 let subpath = (!subpath.is_empty()).then_some(subpath);
3298 Some((package_name, subpath))
3299 } else {
3300 let package_name = first.to_string();
3301 let subpath = parts.collect::<Vec<_>>().join("/");
3302 let subpath = (!subpath.is_empty()).then_some(subpath);
3303 Some((package_name, subpath))
3304 }
3305}
3306
3307fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
3308 let mut current = Some(from_dir);
3309 while let Some(dir) = current {
3310 if package_json_name(dir).as_deref() == Some(package_name) {
3311 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
3312 }
3313 current = dir.parent();
3314 }
3315
3316 find_workspace_root(from_dir)
3317 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
3318}
3319
3320fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3321 let mut current = Some(from_dir);
3322 while let Some(dir) = current {
3323 if is_workspace_root(dir) {
3324 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
3325 }
3326 current = dir.parent();
3327 }
3328 None
3329}
3330
3331fn is_workspace_root(dir: &Path) -> bool {
3332 package_json_value(dir)
3333 .map(|value| !workspace_patterns(&value).is_empty())
3334 .unwrap_or(false)
3335 || !pnpm_workspace_patterns(dir).is_empty()
3336}
3337
3338fn clear_workspace_package_cache() {
3339 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
3340 cache.clear();
3341 }
3342}
3343
3344fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
3345 let workspace_root =
3346 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
3347 let cache_key = (workspace_root.clone(), package_name.to_string());
3348
3349 if let Some(cached) = WORKSPACE_PACKAGE_CACHE
3350 .read()
3351 .ok()
3352 .and_then(|cache| cache.get(&cache_key).cloned())
3353 {
3354 return cached;
3355 }
3356
3357 let resolved = workspace_member_dirs(&workspace_root)
3358 .into_iter()
3359 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
3360 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
3361
3362 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
3363 cache.insert(cache_key, resolved.clone());
3364 }
3365
3366 resolved
3367}
3368
3369fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
3370 let mut patterns = package_json_value(workspace_root)
3371 .map(|package_json| workspace_patterns(&package_json))
3372 .unwrap_or_default();
3373 patterns.extend(pnpm_workspace_patterns(workspace_root));
3374
3375 expand_workspace_patterns(workspace_root, &patterns)
3376}
3377
3378fn workspace_patterns(package_json: &Value) -> Vec<String> {
3379 match package_json.get("workspaces") {
3380 Some(Value::Array(items)) => items
3381 .iter()
3382 .filter_map(non_empty_workspace_pattern)
3383 .collect(),
3384 Some(Value::Object(map)) => map
3385 .get("packages")
3386 .and_then(Value::as_array)
3387 .map(|items| {
3388 items
3389 .iter()
3390 .filter_map(non_empty_workspace_pattern)
3391 .collect()
3392 })
3393 .unwrap_or_default(),
3394 _ => Vec::new(),
3395 }
3396}
3397
3398fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
3399 let pattern = value.as_str()?.trim();
3400 (!pattern.is_empty()).then(|| pattern.to_string())
3401}
3402
3403fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
3404 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
3405 return Vec::new();
3406 };
3407
3408 let mut patterns = Vec::new();
3409 let mut in_packages = false;
3410 for line in source.lines() {
3411 let without_comment = line.split('#').next().unwrap_or("").trim_end();
3412 let trimmed = without_comment.trim();
3413 if trimmed.is_empty() {
3414 continue;
3415 }
3416 if trimmed == "packages:" {
3417 in_packages = true;
3418 continue;
3419 }
3420 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
3421 in_packages = false;
3422 }
3423 if in_packages {
3424 if let Some(pattern) = trimmed.strip_prefix('-') {
3425 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
3426 if !pattern.is_empty() {
3427 patterns.push(pattern.to_string());
3428 }
3429 }
3430 }
3431 }
3432 patterns
3433}
3434
3435fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
3436 let positive_patterns: Vec<&str> = patterns
3437 .iter()
3438 .map(|pattern| pattern.trim())
3439 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
3440 .collect();
3441 if positive_patterns.is_empty() {
3442 return Vec::new();
3443 }
3444
3445 let positives = build_glob_set(&positive_patterns);
3446 let negative_patterns: Vec<&str> = patterns
3447 .iter()
3448 .map(|pattern| pattern.trim())
3449 .filter_map(|pattern| pattern.strip_prefix('!'))
3450 .map(str::trim)
3451 .filter(|pattern| !pattern.is_empty())
3452 .collect();
3453 let negatives = build_glob_set(&negative_patterns);
3454
3455 let mut members = Vec::new();
3456 collect_workspace_member_dirs(
3457 workspace_root,
3458 workspace_root,
3459 &positives,
3460 &negatives,
3461 &mut members,
3462 );
3463 members
3464}
3465
3466fn build_glob_set(patterns: &[&str]) -> GlobSet {
3467 let mut builder = GlobSetBuilder::new();
3468 for pattern in patterns {
3469 if let Ok(glob) = Glob::new(pattern) {
3470 builder.add(glob);
3471 }
3472 }
3473 builder
3474 .build()
3475 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
3476}
3477
3478fn collect_workspace_member_dirs(
3479 workspace_root: &Path,
3480 dir: &Path,
3481 positives: &GlobSet,
3482 negatives: &GlobSet,
3483 members: &mut Vec<PathBuf>,
3484) {
3485 let Ok(entries) = std::fs::read_dir(dir) else {
3486 return;
3487 };
3488
3489 for entry in entries.filter_map(Result::ok) {
3490 let path = entry.path();
3491 let Ok(file_type) = entry.file_type() else {
3492 continue;
3493 };
3494 if !file_type.is_dir() {
3495 continue;
3496 }
3497 let name = entry.file_name();
3498 let name = name.to_string_lossy();
3499 if matches!(
3500 name.as_ref(),
3501 "node_modules" | ".git" | "target" | "dist" | "build"
3502 ) {
3503 continue;
3504 }
3505
3506 if path.join("package.json").is_file() {
3507 if let Ok(rel) = path.strip_prefix(workspace_root) {
3508 let rel = rel.to_string_lossy().replace('\\', "/");
3509 if positives.is_match(&rel) && !negatives.is_match(&rel) {
3510 members.push(path.clone());
3511 }
3512 }
3513 }
3514
3515 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
3516 }
3517}
3518
3519fn package_json_value(dir: &Path) -> Option<Value> {
3520 package_json_like_value(&dir.join("package.json"))
3521}
3522
3523fn package_json_like_value(path: &Path) -> Option<Value> {
3524 let json = std::fs::read_to_string(path).ok()?;
3525 serde_json::from_str(&json).ok()
3526}
3527
3528fn package_json_name(dir: &Path) -> Option<String> {
3529 package_json_value(dir)?
3530 .get("name")?
3531 .as_str()
3532 .map(ToOwned::to_owned)
3533}
3534
3535fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
3536 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
3537
3538 if let Some(exports) = package_json.get("exports") {
3539 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
3540 if let Some(path) = resolve_package_target(package_root, &target) {
3541 return Some(path);
3542 }
3543 }
3544 }
3545
3546 if subpath.is_none() {
3547 for field in ["module", "main"] {
3548 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
3549 if let Some(path) = resolve_package_target(package_root, target) {
3550 return Some(path);
3551 }
3552 }
3553 }
3554 }
3555
3556 resolve_package_fallback(package_root, subpath.as_deref())
3557}
3558
3559fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
3560 let key = subpath
3561 .map(|value| format!("./{value}"))
3562 .unwrap_or_else(|| ".".to_string());
3563
3564 match exports {
3565 Value::String(target) if key == "." => Some(target.clone()),
3566 Value::Object(map) => {
3567 if let Some(target) = map.get(&key).and_then(export_condition_target) {
3568 return Some(target);
3569 }
3570
3571 if let Some(target) = wildcard_export_target(map, &key) {
3572 return Some(target);
3573 }
3574
3575 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
3576 return export_condition_target(exports);
3577 }
3578
3579 None
3580 }
3581 _ => None,
3582 }
3583}
3584
3585fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
3586 for (pattern, target) in map {
3587 let Some(star_index) = pattern.find('*') else {
3588 continue;
3589 };
3590 let (prefix, suffix_with_star) = pattern.split_at(star_index);
3591 let suffix = &suffix_with_star[1..];
3592 if !key.starts_with(prefix) || !key.ends_with(suffix) {
3593 continue;
3594 }
3595 let matched = &key[prefix.len()..key.len() - suffix.len()];
3596 if let Some(target_pattern) = export_condition_target(target) {
3597 return Some(target_pattern.replace('*', matched));
3598 }
3599 }
3600 None
3601}
3602
3603fn export_condition_target(value: &Value) -> Option<String> {
3604 match value {
3605 Value::String(target) => Some(target.clone()),
3606 Value::Object(map) => ["source", "import", "module", "default", "types"]
3607 .into_iter()
3608 .find_map(|field| map.get(field).and_then(export_condition_target)),
3609 _ => None,
3610 }
3611}
3612
3613fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
3614 let target = target.strip_prefix("./").unwrap_or(target);
3615 if let Some(src_relative) = target.strip_prefix("dist/") {
3618 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
3619 return Some(path);
3620 }
3621 }
3622
3623 resolve_file_like_path(&package_root.join(target))
3624}
3625
3626fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
3627 match subpath {
3628 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
3629 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
3630 None => resolve_file_like_path(&package_root.join("src").join("index"))
3631 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
3632 }
3633}
3634
3635fn resolve_reexported_symbol<F, D>(
3636 file: &Path,
3637 symbol_name: &str,
3638 file_exports_symbol: &mut F,
3639 file_default_export_symbol: &mut D,
3640) -> Option<ResolvedSymbol>
3641where
3642 F: FnMut(&Path, &str) -> bool,
3643 D: FnMut(&Path) -> Option<String>,
3644{
3645 let mut visited = HashSet::new();
3646 resolve_reexported_symbol_inner(
3647 file,
3648 symbol_name,
3649 file_exports_symbol,
3650 file_default_export_symbol,
3651 &mut visited,
3652 )
3653}
3654
3655fn resolve_reexported_symbol_inner<F, D>(
3656 file: &Path,
3657 symbol_name: &str,
3658 file_exports_symbol: &mut F,
3659 file_default_export_symbol: &mut D,
3660 visited: &mut HashSet<(PathBuf, String)>,
3661) -> Option<ResolvedSymbol>
3662where
3663 F: FnMut(&Path, &str) -> bool,
3664 D: FnMut(&Path) -> Option<String>,
3665{
3666 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
3667 if !visited.insert((canon.clone(), symbol_name.to_string())) {
3668 return None;
3669 }
3670
3671 let source = std::fs::read_to_string(&canon).ok()?;
3672 let lang = detect_language(&canon)?;
3673 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3674 if symbol_name == "default" {
3675 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
3676 file: canon,
3677 symbol,
3678 });
3679 }
3680 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
3681 file: canon,
3682 symbol: symbol_name.to_string(),
3683 });
3684 }
3685
3686 let grammar = grammar_for(lang);
3687 let mut parser = Parser::new();
3688 parser.set_language(&grammar).ok()?;
3689 let tree = parser.parse(&source, None)?;
3690 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
3691
3692 let mut cursor = tree.root_node().walk();
3693 if !cursor.goto_first_child() {
3694 return None;
3695 }
3696
3697 loop {
3698 let node = cursor.node();
3699 if node.kind() == "export_statement" {
3700 if let Some(target) = resolve_reexport_statement(
3701 &source,
3702 node,
3703 from_dir,
3704 symbol_name,
3705 file_exports_symbol,
3706 file_default_export_symbol,
3707 visited,
3708 ) {
3709 return Some(target);
3710 }
3711 }
3712
3713 if !cursor.goto_next_sibling() {
3714 break;
3715 }
3716 }
3717
3718 if symbol_name == "default" {
3719 if let Some(symbol) = file_default_export_symbol(&canon) {
3720 return Some(ResolvedSymbol {
3721 file: canon,
3722 symbol,
3723 });
3724 }
3725 }
3726
3727 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
3728 return Some(ResolvedSymbol {
3729 file: canon,
3730 symbol,
3731 });
3732 }
3733
3734 if file_exports_symbol(&canon, symbol_name) {
3735 let symbol = symbol_name.to_string();
3736 return Some(ResolvedSymbol {
3737 file: canon,
3738 symbol,
3739 });
3740 }
3741
3742 None
3743}
3744
3745fn resolve_reexport_statement<F, D>(
3746 source: &str,
3747 node: tree_sitter::Node,
3748 from_dir: &Path,
3749 symbol_name: &str,
3750 file_exports_symbol: &mut F,
3751 file_default_export_symbol: &mut D,
3752 visited: &mut HashSet<(PathBuf, String)>,
3753) -> Option<ResolvedSymbol>
3754where
3755 F: FnMut(&Path, &str) -> bool,
3756 D: FnMut(&Path) -> Option<String>,
3757{
3758 let source_node = node.child_by_field_name("source")?;
3759 let module_path = string_literal_content(source, source_node)?;
3760 let target_file = resolve_module_path(from_dir, &module_path)?;
3761 let raw_export = node_text(node, source);
3762
3763 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
3764 return resolve_reexported_symbol_inner(
3765 &target_file,
3766 &source_symbol,
3767 file_exports_symbol,
3768 file_default_export_symbol,
3769 visited,
3770 )
3771 .or(Some(ResolvedSymbol {
3772 file: target_file,
3773 symbol: source_symbol,
3774 }));
3775 }
3776
3777 if raw_export.contains('*') {
3778 return resolve_reexported_symbol_inner(
3779 &target_file,
3780 symbol_name,
3781 file_exports_symbol,
3782 file_default_export_symbol,
3783 visited,
3784 );
3785 }
3786
3787 None
3788}
3789
3790fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
3791 let lang = detect_language(file)?;
3792 let grammar = grammar_for(lang);
3793 let mut parser = Parser::new();
3794 parser.set_language(&grammar).ok()?;
3795 let tree = parser.parse(source, None)?;
3796
3797 let mut cursor = tree.root_node().walk();
3798 if !cursor.goto_first_child() {
3799 return None;
3800 }
3801
3802 loop {
3803 let node = cursor.node();
3804 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
3805 let raw_export = node_text(node, source);
3806 if let Some(source_symbol) =
3807 reexport_clause_source_symbol(&raw_export, requested_export)
3808 {
3809 return Some(source_symbol);
3810 }
3811 }
3812
3813 if !cursor.goto_next_sibling() {
3814 break;
3815 }
3816 }
3817
3818 None
3819}
3820
3821fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3822 let start = raw_export.find('{')? + 1;
3823 let end = raw_export[start..].find('}')? + start;
3824 for specifier in raw_export[start..end].split(',') {
3825 let specifier = specifier.trim();
3826 if specifier.is_empty() {
3827 continue;
3828 }
3829 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3830 if let Some((imported, exported)) = specifier.split_once(" as ") {
3831 if exported.trim() == requested_export {
3832 return Some(imported.trim().to_string());
3833 }
3834 } else if specifier == requested_export {
3835 return Some(requested_export.to_string());
3836 }
3837 }
3838 None
3839}
3840
3841fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3842 let raw = source[node.byte_range()].trim();
3843 let quote = raw.chars().next()?;
3844 if quote != '\'' && quote != '"' {
3845 return None;
3846 }
3847 raw.strip_prefix(quote)
3848 .and_then(|value| value.strip_suffix(quote))
3849 .map(ToOwned::to_owned)
3850}
3851
3852fn find_index_file(dir: &Path) -> Option<PathBuf> {
3854 for name in JS_TS_INDEX_FILES {
3855 let p = dir.join(name);
3856 if p.is_file() {
3857 return Some(std::fs::canonicalize(&p).unwrap_or(p));
3858 }
3859 }
3860 None
3861}
3862
3863fn resolve_aliased_import(
3866 local_name: &str,
3867 import_block: &ImportBlock,
3868 caller_dir: &Path,
3869) -> Option<(String, PathBuf)> {
3870 for imp in &import_block.imports {
3871 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3874 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3875 return Some((original, resolved_path));
3876 }
3877 }
3878 }
3879 None
3880}
3881
3882fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3886 let search = format!(" as {}", local_name);
3889 if let Some(pos) = raw_import.find(&search) {
3890 let before = &raw_import[..pos];
3892 let original = before
3894 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3895 .find(|s| !s.is_empty())?;
3896 return Some(original.to_string());
3897 }
3898 None
3899}
3900
3901pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3909 use ignore::WalkBuilder;
3910
3911 let walker = WalkBuilder::new(root)
3912 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
3917 let name = entry.file_name().to_string_lossy();
3918 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3920 return !matches!(
3921 name.as_ref(),
3922 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3923 | ".tox" | "dist" | "build"
3924 );
3925 }
3926 true
3927 })
3928 .build();
3929
3930 walker
3931 .filter_map(|entry| entry.ok())
3932 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3933 .filter(|entry| detect_language(entry.path()).is_some())
3934 .map(|entry| entry.into_path())
3935}
3936
3937#[cfg(test)]
3942mod tests {
3943 use super::*;
3944 use std::fs;
3945 use tempfile::TempDir;
3946
3947 fn setup_ts_project() -> TempDir {
3949 let dir = TempDir::new().unwrap();
3950
3951 fs::write(
3953 dir.path().join("main.ts"),
3954 r#"import { helper, compute } from './utils';
3955import * as math from './math';
3956
3957export function main() {
3958 const a = helper(1);
3959 const b = compute(a, 2);
3960 const c = math.add(a, b);
3961 return c;
3962}
3963"#,
3964 )
3965 .unwrap();
3966
3967 fs::write(
3969 dir.path().join("utils.ts"),
3970 r#"import { double } from './helpers';
3971
3972export function helper(x: number): number {
3973 return double(x);
3974}
3975
3976export function compute(a: number, b: number): number {
3977 return a + b;
3978}
3979"#,
3980 )
3981 .unwrap();
3982
3983 fs::write(
3985 dir.path().join("helpers.ts"),
3986 r#"export function double(x: number): number {
3987 return x * 2;
3988}
3989
3990export function triple(x: number): number {
3991 return x * 3;
3992}
3993"#,
3994 )
3995 .unwrap();
3996
3997 fs::write(
3999 dir.path().join("math.ts"),
4000 r#"export function add(a: number, b: number): number {
4001 return a + b;
4002}
4003
4004export function subtract(a: number, b: number): number {
4005 return a - b;
4006}
4007"#,
4008 )
4009 .unwrap();
4010
4011 dir
4012 }
4013
4014 fn setup_alias_project() -> TempDir {
4016 let dir = TempDir::new().unwrap();
4017
4018 fs::write(
4019 dir.path().join("main.ts"),
4020 r#"import { helper as h } from './utils';
4021
4022export function main() {
4023 return h(42);
4024}
4025"#,
4026 )
4027 .unwrap();
4028
4029 fs::write(
4030 dir.path().join("utils.ts"),
4031 r#"export function helper(x: number): number {
4032 return x + 1;
4033}
4034"#,
4035 )
4036 .unwrap();
4037
4038 dir
4039 }
4040
4041 fn setup_cycle_project() -> TempDir {
4043 let dir = TempDir::new().unwrap();
4044
4045 fs::write(
4046 dir.path().join("a.ts"),
4047 r#"import { funcB } from './b';
4048
4049export function funcA() {
4050 return funcB();
4051}
4052"#,
4053 )
4054 .unwrap();
4055
4056 fs::write(
4057 dir.path().join("b.ts"),
4058 r#"import { funcA } from './a';
4059
4060export function funcB() {
4061 return funcA();
4062}
4063"#,
4064 )
4065 .unwrap();
4066
4067 dir
4068 }
4069
4070 #[test]
4073 fn callgraph_single_file_call_extraction() {
4074 let dir = setup_ts_project();
4075 let mut graph = CallGraph::new(dir.path().to_path_buf());
4076
4077 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
4078 let main_calls = &file_data.calls_by_symbol["main"];
4079
4080 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
4081 assert!(
4082 callee_names.contains(&"helper"),
4083 "main should call helper, got: {:?}",
4084 callee_names
4085 );
4086 assert!(
4087 callee_names.contains(&"compute"),
4088 "main should call compute, got: {:?}",
4089 callee_names
4090 );
4091 assert!(
4092 callee_names.contains(&"add"),
4093 "main should call math.add (short name: add), got: {:?}",
4094 callee_names
4095 );
4096 }
4097
4098 #[test]
4099 fn callgraph_file_data_has_exports() {
4100 let dir = setup_ts_project();
4101 let mut graph = CallGraph::new(dir.path().to_path_buf());
4102
4103 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4104 assert!(
4105 file_data.exported_symbols.contains(&"helper".to_string()),
4106 "utils.ts should export helper, got: {:?}",
4107 file_data.exported_symbols
4108 );
4109 assert!(
4110 file_data.exported_symbols.contains(&"compute".to_string()),
4111 "utils.ts should export compute, got: {:?}",
4112 file_data.exported_symbols
4113 );
4114 }
4115
4116 #[test]
4119 fn callgraph_resolve_direct_import() {
4120 let dir = setup_ts_project();
4121 let mut graph = CallGraph::new(dir.path().to_path_buf());
4122
4123 let main_path = dir.path().join("main.ts");
4124 let file_data = graph.build_file(&main_path).unwrap();
4125 let import_block = file_data.import_block.clone();
4126
4127 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4128 match edge {
4129 EdgeResolution::Resolved { file, symbol } => {
4130 assert!(
4131 file.ends_with("utils.ts"),
4132 "helper should resolve to utils.ts, got: {:?}",
4133 file
4134 );
4135 assert_eq!(symbol, "helper");
4136 }
4137 EdgeResolution::Unresolved { callee_name } => {
4138 panic!("Expected resolved, got unresolved: {}", callee_name);
4139 }
4140 }
4141 }
4142
4143 #[test]
4144 fn callgraph_resolve_namespace_import() {
4145 let dir = setup_ts_project();
4146 let mut graph = CallGraph::new(dir.path().to_path_buf());
4147
4148 let main_path = dir.path().join("main.ts");
4149 let file_data = graph.build_file(&main_path).unwrap();
4150 let import_block = file_data.import_block.clone();
4151
4152 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4153 match edge {
4154 EdgeResolution::Resolved { file, symbol } => {
4155 assert!(
4156 file.ends_with("math.ts"),
4157 "math.add should resolve to math.ts, got: {:?}",
4158 file
4159 );
4160 assert_eq!(symbol, "add");
4161 }
4162 EdgeResolution::Unresolved { callee_name } => {
4163 panic!("Expected resolved, got unresolved: {}", callee_name);
4164 }
4165 }
4166 }
4167
4168 #[test]
4169 fn callgraph_resolve_aliased_import() {
4170 let dir = setup_alias_project();
4171 let mut graph = CallGraph::new(dir.path().to_path_buf());
4172
4173 let main_path = dir.path().join("main.ts");
4174 let file_data = graph.build_file(&main_path).unwrap();
4175 let import_block = file_data.import_block.clone();
4176
4177 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
4178 match edge {
4179 EdgeResolution::Resolved { file, symbol } => {
4180 assert!(
4181 file.ends_with("utils.ts"),
4182 "h (alias for helper) should resolve to utils.ts, got: {:?}",
4183 file
4184 );
4185 assert_eq!(symbol, "helper");
4186 }
4187 EdgeResolution::Unresolved { callee_name } => {
4188 panic!("Expected resolved, got unresolved: {}", callee_name);
4189 }
4190 }
4191 }
4192
4193 #[test]
4194 fn callgraph_unresolved_edge_marked() {
4195 let dir = setup_ts_project();
4196 let mut graph = CallGraph::new(dir.path().to_path_buf());
4197
4198 let main_path = dir.path().join("main.ts");
4199 let file_data = graph.build_file(&main_path).unwrap();
4200 let import_block = file_data.import_block.clone();
4201
4202 let edge =
4203 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
4204 assert_eq!(
4205 edge,
4206 EdgeResolution::Unresolved {
4207 callee_name: "unknownFunc".to_string()
4208 },
4209 "Unknown callee should be unresolved"
4210 );
4211 }
4212
4213 #[test]
4216 fn callgraph_cycle_detection_stops() {
4217 let dir = setup_cycle_project();
4218 let mut graph = CallGraph::new(dir.path().to_path_buf());
4219
4220 let tree = graph
4222 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
4223 .unwrap();
4224
4225 assert_eq!(tree.name, "funcA");
4226 assert!(tree.resolved);
4227
4228 fn count_depth(node: &CallTreeNode) -> usize {
4231 if node.children.is_empty() {
4232 1
4233 } else {
4234 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
4235 }
4236 }
4237
4238 let depth = count_depth(&tree);
4239 assert!(
4240 depth <= 4,
4241 "Cycle should be detected and bounded, depth was: {}",
4242 depth
4243 );
4244 }
4245
4246 #[test]
4249 fn callgraph_depth_limit_truncates() {
4250 let dir = setup_ts_project();
4251 let mut graph = CallGraph::new(dir.path().to_path_buf());
4252
4253 let tree = graph
4256 .forward_tree(&dir.path().join("main.ts"), "main", 1)
4257 .unwrap();
4258
4259 assert_eq!(tree.name, "main");
4260 assert!(tree.depth_limited, "depth limit should be reported");
4261 assert!(
4262 tree.truncated > 0,
4263 "truncated edge count should be reported"
4264 );
4265
4266 for child in &tree.children {
4268 assert!(
4269 child.children.is_empty(),
4270 "At depth 1, child '{}' should have no children, got {:?}",
4271 child.name,
4272 child.children.len()
4273 );
4274 }
4275 }
4276
4277 #[test]
4278 fn callgraph_depth_zero_no_children() {
4279 let dir = setup_ts_project();
4280 let mut graph = CallGraph::new(dir.path().to_path_buf());
4281
4282 let tree = graph
4283 .forward_tree(&dir.path().join("main.ts"), "main", 0)
4284 .unwrap();
4285
4286 assert_eq!(tree.name, "main");
4287 assert!(
4288 tree.children.is_empty(),
4289 "At depth 0, should have no children"
4290 );
4291 }
4292
4293 #[test]
4296 fn callgraph_forward_tree_cross_file() {
4297 let dir = setup_ts_project();
4298 let mut graph = CallGraph::new(dir.path().to_path_buf());
4299
4300 let tree = graph
4302 .forward_tree(&dir.path().join("main.ts"), "main", 5)
4303 .unwrap();
4304
4305 assert_eq!(tree.name, "main");
4306 assert!(tree.resolved);
4307
4308 let helper_child = tree.children.iter().find(|c| c.name == "helper");
4310 assert!(
4311 helper_child.is_some(),
4312 "main should have helper as child, children: {:?}",
4313 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
4314 );
4315
4316 let helper = helper_child.unwrap();
4317 assert!(
4318 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
4319 "helper should be in utils.ts, got: {}",
4320 helper.file
4321 );
4322
4323 let double_child = helper.children.iter().find(|c| c.name == "double");
4325 assert!(
4326 double_child.is_some(),
4327 "helper should call double, children: {:?}",
4328 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
4329 );
4330
4331 let double = double_child.unwrap();
4332 assert!(
4333 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
4334 "double should be in helpers.ts, got: {}",
4335 double.file
4336 );
4337 }
4338
4339 #[test]
4342 fn callgraph_walker_excludes_gitignored() {
4343 let dir = TempDir::new().unwrap();
4344
4345 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
4347
4348 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
4350 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
4351 fs::write(
4352 dir.path().join("ignored_dir").join("secret.ts"),
4353 "export function secret() {}",
4354 )
4355 .unwrap();
4356
4357 fs::create_dir(dir.path().join("node_modules")).unwrap();
4359 fs::write(
4360 dir.path().join("node_modules").join("dep.ts"),
4361 "export function dep() {}",
4362 )
4363 .unwrap();
4364
4365 std::process::Command::new("git")
4367 .args(["init"])
4368 .current_dir(dir.path())
4369 .output()
4370 .unwrap();
4371
4372 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
4373 let file_names: Vec<String> = files
4374 .iter()
4375 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
4376 .collect();
4377
4378 assert!(
4379 file_names.contains(&"main.ts".to_string()),
4380 "Should include main.ts, got: {:?}",
4381 file_names
4382 );
4383 assert!(
4384 !file_names.contains(&"secret.ts".to_string()),
4385 "Should exclude gitignored secret.ts, got: {:?}",
4386 file_names
4387 );
4388 assert!(
4389 !file_names.contains(&"dep.ts".to_string()),
4390 "Should exclude node_modules, got: {:?}",
4391 file_names
4392 );
4393 }
4394
4395 #[test]
4396 fn callgraph_walker_only_source_files() {
4397 let dir = TempDir::new().unwrap();
4398
4399 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
4400 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
4401 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
4402 fs::write(
4403 dir.path().join("runtime.mjs"),
4404 "export function runtime() {}",
4405 )
4406 .unwrap();
4407 fs::write(
4408 dir.path().join("legacy.cjs"),
4409 "exports.legacy = function() {};",
4410 )
4411 .unwrap();
4412 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
4413 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
4414 fs::write(dir.path().join("data.json"), "{}").unwrap();
4415
4416 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
4417 let file_names: Vec<String> = files
4418 .iter()
4419 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
4420 .collect();
4421
4422 assert!(file_names.contains(&"main.ts".to_string()));
4423 for modern_ext_file in [
4424 "module.mts",
4425 "common.cts",
4426 "runtime.mjs",
4427 "legacy.cjs",
4428 "types.pyi",
4429 ] {
4430 assert!(
4431 file_names.contains(&modern_ext_file.to_string()),
4432 "walker should include {modern_ext_file}, got: {:?}",
4433 file_names
4434 );
4435 }
4436 assert!(
4437 file_names.contains(&"readme.md".to_string()),
4438 "Markdown is now a supported source language"
4439 );
4440 assert!(
4441 file_names.contains(&"data.json".to_string()),
4442 "JSON is now a supported source language"
4443 );
4444 }
4445
4446 #[test]
4449 fn callgraph_find_alias_original_simple() {
4450 let raw = "import { foo as bar } from './utils';";
4451 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
4452 }
4453
4454 #[test]
4455 fn callgraph_find_alias_original_multiple() {
4456 let raw = "import { foo as bar, baz as qux } from './utils';";
4457 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
4458 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
4459 }
4460
4461 #[test]
4462 fn callgraph_find_alias_no_match() {
4463 let raw = "import { foo } from './utils';";
4464 assert_eq!(find_alias_original(raw, "foo"), None);
4465 }
4466
4467 #[test]
4470 fn callgraph_callers_of_direct() {
4471 let dir = setup_ts_project();
4472 let mut graph = CallGraph::new(dir.path().to_path_buf());
4473
4474 let result = graph
4476 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
4477 .unwrap();
4478
4479 assert_eq!(result.symbol, "double");
4480 assert!(result.total_callers > 0, "double should have callers");
4481 assert!(result.scanned_files > 0, "should have scanned files");
4482
4483 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
4485 assert!(
4486 utils_group.is_some(),
4487 "double should be called from utils.ts, groups: {:?}",
4488 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
4489 );
4490
4491 let group = utils_group.unwrap();
4492 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
4493 assert!(
4494 helper_caller.is_some(),
4495 "double should be called by helper, callers: {:?}",
4496 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
4497 );
4498 }
4499
4500 #[test]
4501 fn callgraph_callers_of_no_callers() {
4502 let dir = setup_ts_project();
4503 let mut graph = CallGraph::new(dir.path().to_path_buf());
4504
4505 let result = graph
4507 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
4508 .unwrap();
4509
4510 assert_eq!(result.symbol, "main");
4511 assert_eq!(result.total_callers, 0, "main should have no callers");
4512 assert!(result.callers.is_empty());
4513 }
4514
4515 #[test]
4516 fn callgraph_callers_recursive_depth() {
4517 let dir = setup_ts_project();
4518 let mut graph = CallGraph::new(dir.path().to_path_buf());
4519
4520 let result = graph
4524 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
4525 .unwrap();
4526
4527 assert!(
4528 result.total_callers >= 2,
4529 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
4530 result.total_callers
4531 );
4532
4533 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
4535 assert!(
4536 main_group.is_some(),
4537 "recursive callers should include main.ts, groups: {:?}",
4538 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
4539 );
4540 }
4541
4542 #[test]
4543 fn callgraph_invalidate_file_clears_reverse_index() {
4544 let dir = setup_ts_project();
4545 let mut graph = CallGraph::new(dir.path().to_path_buf());
4546
4547 let _ = graph
4549 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
4550 .unwrap();
4551 assert!(
4552 graph.reverse_index.is_some(),
4553 "reverse index should be built"
4554 );
4555
4556 graph.invalidate_file(&dir.path().join("utils.ts"));
4558
4559 assert!(
4561 graph.reverse_index.is_none(),
4562 "invalidate_file should clear reverse index"
4563 );
4564 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
4566 assert!(
4567 !graph.data.contains_key(&canon),
4568 "invalidate_file should remove file from data cache"
4569 );
4570 assert!(
4572 graph.project_files.is_none(),
4573 "invalidate_file should clear project_files"
4574 );
4575 }
4576
4577 #[test]
4580 fn is_entry_point_exported_function() {
4581 assert!(is_entry_point(
4582 "handleRequest",
4583 &SymbolKind::Function,
4584 true,
4585 LangId::TypeScript
4586 ));
4587 }
4588
4589 #[test]
4590 fn is_entry_point_exported_method_is_not_entry() {
4591 assert!(!is_entry_point(
4593 "handleRequest",
4594 &SymbolKind::Method,
4595 true,
4596 LangId::TypeScript
4597 ));
4598 }
4599
4600 #[test]
4601 fn is_entry_point_main_init_patterns() {
4602 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
4603 assert!(
4604 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
4605 "{} should be an entry point",
4606 name
4607 );
4608 }
4609 }
4610
4611 #[test]
4612 fn is_entry_point_test_patterns_ts() {
4613 assert!(is_entry_point(
4614 "describe",
4615 &SymbolKind::Function,
4616 false,
4617 LangId::TypeScript
4618 ));
4619 assert!(is_entry_point(
4620 "it",
4621 &SymbolKind::Function,
4622 false,
4623 LangId::TypeScript
4624 ));
4625 assert!(is_entry_point(
4626 "test",
4627 &SymbolKind::Function,
4628 false,
4629 LangId::TypeScript
4630 ));
4631 assert!(is_entry_point(
4632 "testValidation",
4633 &SymbolKind::Function,
4634 false,
4635 LangId::TypeScript
4636 ));
4637 assert!(is_entry_point(
4638 "specHelper",
4639 &SymbolKind::Function,
4640 false,
4641 LangId::TypeScript
4642 ));
4643 }
4644
4645 #[test]
4646 fn is_entry_point_test_patterns_python() {
4647 assert!(is_entry_point(
4648 "test_login",
4649 &SymbolKind::Function,
4650 false,
4651 LangId::Python
4652 ));
4653 assert!(is_entry_point(
4654 "setUp",
4655 &SymbolKind::Function,
4656 false,
4657 LangId::Python
4658 ));
4659 assert!(is_entry_point(
4660 "tearDown",
4661 &SymbolKind::Function,
4662 false,
4663 LangId::Python
4664 ));
4665 assert!(!is_entry_point(
4667 "testSomething",
4668 &SymbolKind::Function,
4669 false,
4670 LangId::Python
4671 ));
4672 }
4673
4674 #[test]
4675 fn is_entry_point_test_patterns_rust() {
4676 assert!(is_entry_point(
4677 "test_parse",
4678 &SymbolKind::Function,
4679 false,
4680 LangId::Rust
4681 ));
4682 assert!(!is_entry_point(
4683 "TestSomething",
4684 &SymbolKind::Function,
4685 false,
4686 LangId::Rust
4687 ));
4688 }
4689
4690 #[test]
4691 fn is_entry_point_test_patterns_go() {
4692 assert!(is_entry_point(
4693 "TestParsing",
4694 &SymbolKind::Function,
4695 false,
4696 LangId::Go
4697 ));
4698 assert!(!is_entry_point(
4700 "testParsing",
4701 &SymbolKind::Function,
4702 false,
4703 LangId::Go
4704 ));
4705 }
4706
4707 #[test]
4708 fn is_entry_point_non_exported_non_main_is_not_entry() {
4709 assert!(!is_entry_point(
4710 "helperUtil",
4711 &SymbolKind::Function,
4712 false,
4713 LangId::TypeScript
4714 ));
4715 }
4716
4717 #[test]
4720 fn callgraph_symbol_metadata_populated() {
4721 let dir = setup_ts_project();
4722 let mut graph = CallGraph::new(dir.path().to_path_buf());
4723
4724 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4725 assert!(
4726 file_data.symbol_metadata.contains_key("helper"),
4727 "symbol_metadata should contain helper"
4728 );
4729 let meta = &file_data.symbol_metadata["helper"];
4730 assert_eq!(meta.kind, SymbolKind::Function);
4731 assert!(meta.exported, "helper should be exported");
4732 }
4733
4734 fn setup_trace_project() -> TempDir {
4750 let dir = TempDir::new().unwrap();
4751
4752 fs::write(
4753 dir.path().join("main.ts"),
4754 r#"import { processData } from './utils';
4755
4756export function main() {
4757 const result = processData("hello");
4758 return result;
4759}
4760"#,
4761 )
4762 .unwrap();
4763
4764 fs::write(
4765 dir.path().join("service.ts"),
4766 r#"import { processData } from './utils';
4767
4768export function handleRequest(input: string): string {
4769 return processData(input);
4770}
4771"#,
4772 )
4773 .unwrap();
4774
4775 fs::write(
4776 dir.path().join("utils.ts"),
4777 r#"import { validate } from './helpers';
4778
4779export function processData(input: string): string {
4780 const valid = validate(input);
4781 if (!valid) {
4782 throw new Error("invalid input");
4783 }
4784 return input.toUpperCase();
4785}
4786"#,
4787 )
4788 .unwrap();
4789
4790 fs::write(
4791 dir.path().join("helpers.ts"),
4792 r#"export function validate(input: string): boolean {
4793 return checkFormat(input);
4794}
4795
4796function checkFormat(input: string): boolean {
4797 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
4798}
4799"#,
4800 )
4801 .unwrap();
4802
4803 fs::write(
4804 dir.path().join("test_helpers.ts"),
4805 r#"import { validate } from './helpers';
4806
4807function testValidation() {
4808 const result = validate("hello");
4809 console.log(result);
4810}
4811"#,
4812 )
4813 .unwrap();
4814
4815 std::process::Command::new("git")
4817 .args(["init"])
4818 .current_dir(dir.path())
4819 .output()
4820 .unwrap();
4821
4822 dir
4823 }
4824
4825 #[test]
4826 fn trace_to_multi_path() {
4827 let dir = setup_trace_project();
4828 let mut graph = CallGraph::new(dir.path().to_path_buf());
4829
4830 let result = graph
4831 .trace_to(
4832 &dir.path().join("helpers.ts"),
4833 "checkFormat",
4834 10,
4835 usize::MAX,
4836 )
4837 .unwrap();
4838
4839 assert_eq!(result.target_symbol, "checkFormat");
4840 assert!(
4841 result.total_paths >= 2,
4842 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
4843 result.total_paths,
4844 result
4845 .paths
4846 .iter()
4847 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
4848 .collect::<Vec<_>>()
4849 );
4850
4851 for path in &result.paths {
4853 assert!(
4854 path.hops.first().unwrap().is_entry_point,
4855 "First hop should be an entry point, got: {}",
4856 path.hops.first().unwrap().symbol
4857 );
4858 assert_eq!(
4859 path.hops.last().unwrap().symbol,
4860 "checkFormat",
4861 "Last hop should be checkFormat"
4862 );
4863 }
4864
4865 assert!(
4867 result.entry_points_found >= 2,
4868 "should find at least 2 entry points, got {}",
4869 result.entry_points_found
4870 );
4871 }
4872
4873 #[test]
4874 fn trace_to_single_path() {
4875 let dir = setup_trace_project();
4876 let mut graph = CallGraph::new(dir.path().to_path_buf());
4877
4878 let result = graph
4882 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
4883 .unwrap();
4884
4885 assert_eq!(result.target_symbol, "validate");
4886 assert!(
4887 result.total_paths >= 2,
4888 "validate should have at least 2 paths, got {}",
4889 result.total_paths
4890 );
4891 }
4892
4893 #[test]
4894 fn trace_to_cycle_detection() {
4895 let dir = setup_cycle_project();
4896 let mut graph = CallGraph::new(dir.path().to_path_buf());
4897
4898 let result = graph
4900 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
4901 .unwrap();
4902
4903 assert_eq!(result.target_symbol, "funcA");
4905 }
4906
4907 #[test]
4908 fn trace_to_depth_limit() {
4909 let dir = setup_trace_project();
4910 let mut graph = CallGraph::new(dir.path().to_path_buf());
4911
4912 let result = graph
4914 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
4915 .unwrap();
4916
4917 assert_eq!(result.target_symbol, "checkFormat");
4921
4922 let deep_result = graph
4924 .trace_to(
4925 &dir.path().join("helpers.ts"),
4926 "checkFormat",
4927 10,
4928 usize::MAX,
4929 )
4930 .unwrap();
4931
4932 assert!(
4933 result.total_paths <= deep_result.total_paths,
4934 "shallow trace should find <= paths compared to deep: {} vs {}",
4935 result.total_paths,
4936 deep_result.total_paths
4937 );
4938 }
4939
4940 #[test]
4941 fn trace_to_entry_point_target() {
4942 let dir = setup_trace_project();
4943 let mut graph = CallGraph::new(dir.path().to_path_buf());
4944
4945 let result = graph
4947 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
4948 .unwrap();
4949
4950 assert_eq!(result.target_symbol, "main");
4951 assert!(
4952 result.total_paths >= 1,
4953 "main should have at least 1 path (itself), got {}",
4954 result.total_paths
4955 );
4956 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
4958 assert!(
4959 trivial.is_some(),
4960 "should have a trivial path with just the entry point itself"
4961 );
4962 }
4963
4964 #[test]
4965 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
4966 let dir = TempDir::new().unwrap();
4967 fs::write(
4968 dir.path().join("main.ts"),
4969 r#"import * as lib from './index';
4970
4971export function main() {
4972 lib.helper();
4973 lib.hidden();
4974}
4975"#,
4976 )
4977 .unwrap();
4978 fs::write(
4979 dir.path().join("index.ts"),
4980 "export { helper } from './utils';\n",
4981 )
4982 .unwrap();
4983 fs::write(
4984 dir.path().join("utils.ts"),
4985 r#"export function helper() {}
4986function hidden() {}
4987"#,
4988 )
4989 .unwrap();
4990
4991 let mut graph = CallGraph::new(dir.path().to_path_buf());
4992 let main_path = dir.path().join("main.ts");
4993 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
4994
4995 let helper =
4996 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
4997 match helper {
4998 EdgeResolution::Resolved { file, symbol } => {
4999 assert!(
5000 file.ends_with("utils.ts"),
5001 "helper should resolve through barrel: {file:?}"
5002 );
5003 assert_eq!(symbol, "helper");
5004 }
5005 other => panic!("expected helper to resolve through barrel, got {other:?}"),
5006 }
5007
5008 let hidden =
5009 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
5010 assert_eq!(
5011 hidden,
5012 EdgeResolution::Unresolved {
5013 callee_name: "hidden".to_string()
5014 }
5015 );
5016 }
5017
5018 #[test]
5019 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
5020 let dir = TempDir::new().unwrap();
5021 fs::write(
5022 dir.path().join("package.json"),
5023 r#"{"workspaces":["packages/*"]}"#,
5024 )
5025 .unwrap();
5026 let package_dir = dir.path().join("packages/lib");
5027 fs::create_dir_all(package_dir.join("src")).unwrap();
5028 fs::create_dir_all(package_dir.join("dist")).unwrap();
5029 fs::write(
5030 package_dir.join("package.json"),
5031 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
5032 )
5033 .unwrap();
5034 fs::write(
5035 package_dir.join("src/index.mts"),
5036 "export function helper() {}\n",
5037 )
5038 .unwrap();
5039 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
5040
5041 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
5042 assert!(
5043 resolved.ends_with("src/index.mts"),
5044 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
5045 );
5046 }
5047
5048 #[test]
5049 fn unresolved_member_calls_do_not_become_same_file_callers() {
5050 let dir = TempDir::new().unwrap();
5051 fs::write(
5052 dir.path().join("main.ts"),
5053 r#"function caller() {
5054 db.connect();
5055}
5056
5057function connect() {}
5058"#,
5059 )
5060 .unwrap();
5061
5062 let mut graph = CallGraph::new(dir.path().to_path_buf());
5063 let result = graph
5064 .callers_of(&dir.path().join("main.ts"), "connect", 1, usize::MAX)
5065 .unwrap();
5066
5067 assert_eq!(
5068 result.total_callers, 0,
5069 "db.connect() must not call local connect"
5070 );
5071 }
5072
5073 #[test]
5074 fn same_named_methods_use_scoped_symbol_identity() {
5075 let dir = TempDir::new().unwrap();
5076 fs::write(
5077 dir.path().join("classes.ts"),
5078 r#"class A {
5079 run() { helperA(); }
5080}
5081
5082class B {
5083 run() { helperB(); }
5084}
5085
5086function helperA() {}
5087function helperB() {}
5088"#,
5089 )
5090 .unwrap();
5091
5092 let mut graph = CallGraph::new(dir.path().to_path_buf());
5093 let path = dir.path().join("classes.ts");
5094 let data = graph.build_file(&path).unwrap();
5095
5096 assert!(
5097 data.symbol_metadata.contains_key("A::run"),
5098 "A::run metadata missing"
5099 );
5100 assert!(
5101 data.symbol_metadata.contains_key("B::run"),
5102 "B::run metadata missing"
5103 );
5104 assert!(
5105 data.calls_by_symbol["A::run"]
5106 .iter()
5107 .any(|call| call.callee_name == "helperA"),
5108 "A::run calls should not be overwritten"
5109 );
5110 assert!(
5111 data.calls_by_symbol["B::run"]
5112 .iter()
5113 .any(|call| call.callee_name == "helperB"),
5114 "B::run calls should not be overwritten"
5115 );
5116
5117 assert!(matches!(
5118 graph.resolve_symbol_query(&path, "run"),
5119 Err(AftError::AmbiguousSymbol { .. })
5120 ));
5121 assert_eq!(
5122 graph.resolve_symbol_query(&path, "A::run").unwrap(),
5123 "A::run"
5124 );
5125 }
5126
5127 #[test]
5128 fn trace_to_counts_same_named_entry_points_by_file_and_symbol() {
5129 let dir = TempDir::new().unwrap();
5130 fs::create_dir_all(dir.path().join("web")).unwrap();
5131 fs::create_dir_all(dir.path().join("cli")).unwrap();
5132 fs::write(
5133 dir.path().join("target.ts"),
5134 r#"export function target() {
5135 leaf();
5136}
5137
5138function leaf() {}
5139"#,
5140 )
5141 .unwrap();
5142 fs::write(
5143 dir.path().join("web/main.ts"),
5144 r#"import { target } from '../target';
5145
5146export function main() {
5147 target();
5148}
5149"#,
5150 )
5151 .unwrap();
5152 fs::write(
5153 dir.path().join("cli/main.ts"),
5154 r#"import { target } from '../target';
5155
5156export function main() {
5157 target();
5158}
5159"#,
5160 )
5161 .unwrap();
5162
5163 let mut graph = CallGraph::new(dir.path().to_path_buf());
5164 let result = graph
5165 .trace_to(&dir.path().join("target.ts"), "leaf", 10, usize::MAX)
5166 .unwrap();
5167
5168 assert_eq!(
5169 result.total_paths, 3,
5170 "target plus two main entry paths expected"
5171 );
5172 assert_eq!(
5173 result.entry_points_found, 3,
5174 "same-named main entry points in different files must both count"
5175 );
5176 }
5177
5178 #[test]
5179 fn callers_and_impact_report_depth_truncation() {
5180 let dir = setup_ts_project();
5181 let mut graph = CallGraph::new(dir.path().to_path_buf());
5182
5183 let callers = graph
5184 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5185 .unwrap();
5186 assert!(
5187 callers.depth_limited,
5188 "callers should report omitted transitive callers"
5189 );
5190 assert!(
5191 callers.truncated > 0,
5192 "callers should report truncated edge count"
5193 );
5194
5195 let impact = graph
5196 .impact(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5197 .unwrap();
5198 assert!(
5199 impact.depth_limited,
5200 "impact should report omitted transitive callers"
5201 );
5202 assert!(
5203 impact.truncated > 0,
5204 "impact should report truncated edge count"
5205 );
5206 }
5207
5208 #[test]
5211 fn extract_parameters_typescript() {
5212 let params = extract_parameters(
5213 "function processData(input: string, count: number): void",
5214 LangId::TypeScript,
5215 );
5216 assert_eq!(params, vec!["input", "count"]);
5217 }
5218
5219 #[test]
5220 fn extract_parameters_typescript_optional() {
5221 let params = extract_parameters(
5222 "function fetch(url: string, options?: RequestInit): Promise<Response>",
5223 LangId::TypeScript,
5224 );
5225 assert_eq!(params, vec!["url", "options"]);
5226 }
5227
5228 #[test]
5229 fn extract_parameters_typescript_defaults() {
5230 let params = extract_parameters(
5231 "function greet(name: string, greeting: string = \"hello\"): string",
5232 LangId::TypeScript,
5233 );
5234 assert_eq!(params, vec!["name", "greeting"]);
5235 }
5236
5237 #[test]
5238 fn extract_parameters_typescript_rest() {
5239 let params = extract_parameters(
5240 "function sum(...numbers: number[]): number",
5241 LangId::TypeScript,
5242 );
5243 assert_eq!(params, vec!["numbers"]);
5244 }
5245
5246 #[test]
5247 fn extract_parameters_python_self_skipped() {
5248 let params = extract_parameters(
5249 "def process(self, data: str, count: int) -> bool",
5250 LangId::Python,
5251 );
5252 assert_eq!(params, vec!["data", "count"]);
5253 }
5254
5255 #[test]
5256 fn extract_parameters_python_no_self() {
5257 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
5258 assert_eq!(params, vec!["input"]);
5259 }
5260
5261 #[test]
5262 fn extract_parameters_python_star_args() {
5263 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
5264 assert_eq!(params, vec!["args", "kwargs"]);
5265 }
5266
5267 #[test]
5268 fn extract_parameters_rust_self_skipped() {
5269 let params = extract_parameters(
5270 "fn process(&self, data: &str, count: usize) -> bool",
5271 LangId::Rust,
5272 );
5273 assert_eq!(params, vec!["data", "count"]);
5274 }
5275
5276 #[test]
5277 fn extract_parameters_rust_mut_self_skipped() {
5278 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
5279 assert_eq!(params, vec!["value"]);
5280 }
5281
5282 #[test]
5283 fn extract_parameters_rust_no_self() {
5284 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
5285 assert_eq!(params, vec!["input"]);
5286 }
5287
5288 #[test]
5289 fn extract_parameters_rust_mut_param() {
5290 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
5291 assert_eq!(params, vec!["buf", "len"]);
5292 }
5293
5294 #[test]
5295 fn extract_parameters_go() {
5296 let params = extract_parameters(
5297 "func ProcessData(input string, count int) error",
5298 LangId::Go,
5299 );
5300 assert_eq!(params, vec!["input", "count"]);
5301 }
5302
5303 #[test]
5304 fn extract_parameters_empty() {
5305 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
5306 assert!(
5307 params.is_empty(),
5308 "no-arg function should return empty params"
5309 );
5310 }
5311
5312 #[test]
5313 fn extract_parameters_no_parens() {
5314 let params = extract_parameters("const x = 42", LangId::TypeScript);
5315 assert!(params.is_empty(), "no parens should return empty params");
5316 }
5317
5318 #[test]
5319 fn extract_parameters_javascript() {
5320 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
5321 assert_eq!(params, vec!["event", "target"]);
5322 }
5323}