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