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, 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>";
39
40#[derive(Debug, Clone)]
42pub struct CallSite {
43 pub callee_name: String,
45 pub full_callee: String,
47 pub line: u32,
49 pub byte_start: usize,
51 pub byte_end: usize,
52}
53
54#[derive(Debug, Clone, Serialize)]
56pub struct SymbolMeta {
57 pub kind: SymbolKind,
59 pub exported: bool,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub signature: Option<String>,
64 pub line: u32,
66 pub range: Range,
68}
69
70#[derive(Debug, Clone)]
73pub struct FileCallData {
74 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
76 pub exported_symbols: Vec<String>,
78 pub symbol_metadata: HashMap<String, SymbolMeta>,
80 pub default_export_symbol: Option<String>,
82 pub import_block: ImportBlock,
84 pub lang: LangId,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum EdgeResolution {
91 Resolved { file: PathBuf, symbol: String },
93 Unresolved { callee_name: String },
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98struct ResolvedSymbol {
99 file: PathBuf,
100 symbol: String,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct CallerSite {
106 pub caller_file: PathBuf,
108 pub caller_symbol: String,
110 pub line: u32,
112 pub col: u32,
114 pub resolved: bool,
116}
117
118#[derive(Debug, Clone)]
119struct IndexedCallerSite {
120 caller_file: SharedPath,
121 caller_symbol: SharedStr,
122 line: u32,
123 col: u32,
124 resolved: bool,
125}
126
127#[derive(Debug, Clone, Serialize)]
129pub struct CallerGroup {
130 pub file: String,
132 pub callers: Vec<CallerEntry>,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct CallerEntry {
139 pub symbol: String,
140 pub line: u32,
142}
143
144#[derive(Debug, Clone, Serialize)]
146pub struct CallersResult {
147 pub symbol: String,
149 pub file: String,
151 pub callers: Vec<CallerGroup>,
153 pub total_callers: usize,
155 pub scanned_files: usize,
157}
158
159#[derive(Debug, Clone, Serialize)]
161pub struct CallTreeNode {
162 pub name: String,
164 pub file: String,
166 pub line: u32,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub signature: Option<String>,
171 pub resolved: bool,
173 pub children: Vec<CallTreeNode>,
175}
176
177const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
183
184pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
191 if exported && *kind == SymbolKind::Function {
193 return true;
194 }
195
196 let lower = name.to_lowercase();
198 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
199 return true;
200 }
201
202 match lang {
204 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
205 matches!(lower.as_str(), "describe" | "it" | "test")
207 || lower.starts_with("test")
208 || lower.starts_with("spec")
209 }
210 LangId::Python => {
211 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
213 }
214 LangId::Rust => {
215 lower.starts_with("test_")
217 }
218 LangId::Go => {
219 name.starts_with("Test")
221 }
222 LangId::C
223 | LangId::Cpp
224 | LangId::Zig
225 | LangId::CSharp
226 | LangId::Bash
227 | LangId::Solidity
228 | LangId::Vue
229 | LangId::Json
230 | LangId::Scala
231 | LangId::Html
232 | LangId::Markdown => false,
233 }
234}
235
236#[derive(Debug, Clone, Serialize)]
242pub struct TraceHop {
243 pub symbol: String,
245 pub file: String,
247 pub line: u32,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub signature: Option<String>,
252 pub is_entry_point: bool,
254}
255
256#[derive(Debug, Clone, Serialize)]
258pub struct TracePath {
259 pub hops: Vec<TraceHop>,
261}
262
263#[derive(Debug, Clone, Serialize)]
265pub struct TraceToResult {
266 pub target_symbol: String,
268 pub target_file: String,
270 pub paths: Vec<TracePath>,
272 pub total_paths: usize,
274 pub entry_points_found: usize,
276 pub max_depth_reached: bool,
278 pub truncated_paths: usize,
280}
281
282#[derive(Debug, Clone, Serialize)]
288pub struct ImpactCaller {
289 pub caller_symbol: String,
291 pub caller_file: String,
293 pub line: u32,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub signature: Option<String>,
298 pub is_entry_point: bool,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub call_expression: Option<String>,
303 pub parameters: Vec<String>,
305}
306
307#[derive(Debug, Clone, Serialize)]
309pub struct ImpactResult {
310 pub symbol: String,
312 pub file: String,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub signature: Option<String>,
317 pub parameters: Vec<String>,
319 pub total_affected: usize,
321 pub affected_files: usize,
323 pub callers: Vec<ImpactCaller>,
325}
326
327#[derive(Debug, Clone, Serialize)]
333pub struct DataFlowHop {
334 pub file: String,
336 pub symbol: String,
338 pub variable: String,
340 pub line: u32,
342 pub flow_type: String,
344 pub approximate: bool,
346}
347
348#[derive(Debug, Clone, Serialize)]
351pub struct TraceDataResult {
352 pub expression: String,
354 pub origin_file: String,
356 pub origin_symbol: String,
358 pub hops: Vec<DataFlowHop>,
360 pub depth_limited: bool,
362}
363
364pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
370 let start = match signature.find('(') {
372 Some(i) => i + 1,
373 None => return Vec::new(),
374 };
375 let end = match signature[start..].find(')') {
376 Some(i) => start + i,
377 None => return Vec::new(),
378 };
379
380 let params_str = &signature[start..end].trim();
381 if params_str.is_empty() {
382 return Vec::new();
383 }
384
385 let parts = split_params(params_str);
387
388 let mut result = Vec::new();
389 for part in parts {
390 let trimmed = part.trim();
391 if trimmed.is_empty() {
392 continue;
393 }
394
395 match lang {
397 LangId::Rust => {
398 if trimmed == "self"
399 || trimmed == "mut self"
400 || trimmed.starts_with("&self")
401 || trimmed.starts_with("&mut self")
402 {
403 continue;
404 }
405 }
406 LangId::Python => {
407 if trimmed == "self" || trimmed.starts_with("self:") {
408 continue;
409 }
410 }
411 _ => {}
412 }
413
414 let name = extract_param_name(trimmed, lang);
416 if !name.is_empty() {
417 result.push(name);
418 }
419 }
420
421 result
422}
423
424fn split_params(s: &str) -> Vec<String> {
426 let mut parts = Vec::new();
427 let mut current = String::new();
428 let mut depth = 0i32;
429
430 for ch in s.chars() {
431 match ch {
432 '<' | '[' | '{' | '(' => {
433 depth += 1;
434 current.push(ch);
435 }
436 '>' | ']' | '}' | ')' => {
437 depth -= 1;
438 current.push(ch);
439 }
440 ',' if depth == 0 => {
441 parts.push(current.clone());
442 current.clear();
443 }
444 _ => {
445 current.push(ch);
446 }
447 }
448 }
449 if !current.is_empty() {
450 parts.push(current);
451 }
452 parts
453}
454
455fn extract_param_name(param: &str, lang: LangId) -> String {
463 let trimmed = param.trim();
464
465 let working = if trimmed.starts_with("...") {
467 &trimmed[3..]
468 } else if trimmed.starts_with("**") {
469 &trimmed[2..]
470 } else if trimmed.starts_with('*') && lang == LangId::Python {
471 &trimmed[1..]
472 } else {
473 trimmed
474 };
475
476 let working = if lang == LangId::Rust && working.starts_with("mut ") {
478 &working[4..]
479 } else {
480 working
481 };
482
483 let name = working
486 .split(|c: char| c == ':' || c == '=')
487 .next()
488 .unwrap_or("")
489 .trim();
490
491 let name = name.trim_end_matches('?');
493
494 if lang == LangId::Go && !name.contains(' ') {
496 return name.to_string();
497 }
498 if lang == LangId::Go {
499 return name.split_whitespace().next().unwrap_or("").to_string();
500 }
501
502 name.to_string()
503}
504
505pub struct CallGraph {
514 data: HashMap<PathBuf, FileCallData>,
516 project_root: PathBuf,
518 project_files: Option<Vec<PathBuf>>,
520 reverse_index: Option<ReverseIndex>,
523}
524
525impl CallGraph {
526 pub fn new(project_root: PathBuf) -> Self {
528 clear_workspace_package_cache();
529 Self {
530 data: HashMap::new(),
531 project_root,
532 project_files: None,
533 reverse_index: None,
534 }
535 }
536
537 pub fn project_root(&self) -> &Path {
539 &self.project_root
540 }
541
542 fn resolve_cross_file_edge_with_exports<F, D>(
543 full_callee: &str,
544 short_name: &str,
545 caller_file: &Path,
546 import_block: &ImportBlock,
547 mut file_exports_symbol: F,
548 mut file_default_export_symbol: D,
549 ) -> EdgeResolution
550 where
551 F: FnMut(&Path, &str) -> bool,
552 D: FnMut(&Path) -> Option<String>,
553 {
554 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
555
556 if full_callee.contains('.') {
558 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
559 if parts.len() == 2 {
560 let namespace = parts[0];
561 let member = parts[1];
562
563 for imp in &import_block.imports {
564 if imp.namespace_import.as_deref() == Some(namespace) {
565 if let Some(resolved_path) =
566 resolve_module_path(caller_dir, &imp.module_path)
567 {
568 return EdgeResolution::Resolved {
569 file: resolved_path,
570 symbol: member.to_owned(),
571 };
572 }
573 }
574 }
575 }
576 }
577
578 for imp in &import_block.imports {
580 if imp.names.iter().any(|name| name == short_name) {
582 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
583 let target = resolve_reexported_symbol(
584 &resolved_path,
585 short_name,
586 &mut file_exports_symbol,
587 &mut file_default_export_symbol,
588 )
589 .unwrap_or(ResolvedSymbol {
590 file: resolved_path,
591 symbol: short_name.to_owned(),
592 });
593 return EdgeResolution::Resolved {
594 file: target.file,
595 symbol: target.symbol,
596 };
597 }
598 }
599
600 if imp.default_import.as_deref() == Some(short_name) {
602 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
603 let target = resolve_reexported_symbol(
604 &resolved_path,
605 "default",
606 &mut file_exports_symbol,
607 &mut file_default_export_symbol,
608 )
609 .unwrap_or_else(|| ResolvedSymbol {
610 symbol: file_default_export_symbol(&resolved_path)
611 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
612 file: resolved_path,
613 });
614 return EdgeResolution::Resolved {
615 file: target.file,
616 symbol: target.symbol,
617 };
618 }
619 }
620 }
621
622 if let Some((original_name, resolved_path)) =
627 resolve_aliased_import(short_name, import_block, caller_dir)
628 {
629 let target = resolve_reexported_symbol(
630 &resolved_path,
631 &original_name,
632 &mut file_exports_symbol,
633 &mut file_default_export_symbol,
634 )
635 .unwrap_or(ResolvedSymbol {
636 file: resolved_path,
637 symbol: original_name,
638 });
639 return EdgeResolution::Resolved {
640 file: target.file,
641 symbol: target.symbol,
642 };
643 }
644
645 for imp in &import_block.imports {
648 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
649 if resolved_path.is_dir() {
651 if let Some(index_path) = find_index_file(&resolved_path) {
652 if file_exports_symbol(&index_path, short_name) {
654 return EdgeResolution::Resolved {
655 file: index_path,
656 symbol: short_name.to_owned(),
657 };
658 }
659 }
660 } else if file_exports_symbol(&resolved_path, short_name) {
661 return EdgeResolution::Resolved {
662 file: resolved_path,
663 symbol: short_name.to_owned(),
664 };
665 }
666 }
667 }
668
669 EdgeResolution::Unresolved {
670 callee_name: short_name.to_owned(),
671 }
672 }
673
674 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
676 let canon = self.canonicalize(path)?;
677
678 if !self.data.contains_key(&canon) {
679 let file_data = build_file_data(&canon)?;
680 self.data.insert(canon.clone(), file_data);
681 }
682
683 Ok(&self.data[&canon])
684 }
685
686 pub fn resolve_cross_file_edge(
691 &mut self,
692 full_callee: &str,
693 short_name: &str,
694 caller_file: &Path,
695 import_block: &ImportBlock,
696 ) -> EdgeResolution {
697 let graph = RefCell::new(self);
698 Self::resolve_cross_file_edge_with_exports(
699 full_callee,
700 short_name,
701 caller_file,
702 import_block,
703 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
704 |path| graph.borrow_mut().file_default_export_symbol(path),
705 )
706 }
707
708 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
710 match self.build_file(path) {
711 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
712 Err(_) => false,
713 }
714 }
715
716 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
717 self.build_file(path)
718 .ok()
719 .and_then(|data| data.default_export_symbol.clone())
720 }
721
722 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
723 self.lookup_file_data(path)
724 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
725 .unwrap_or(false)
726 }
727
728 fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
729 self.lookup_file_data(path)
730 .and_then(|data| data.default_export_symbol.clone())
731 }
732
733 pub fn forward_tree(
738 &mut self,
739 file: &Path,
740 symbol: &str,
741 max_depth: usize,
742 ) -> Result<CallTreeNode, AftError> {
743 let mut visited = HashSet::new();
744 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
745 }
746
747 fn forward_tree_inner(
748 &mut self,
749 file: &Path,
750 symbol: &str,
751 max_depth: usize,
752 current_depth: usize,
753 visited: &mut HashSet<(PathBuf, String)>,
754 ) -> Result<CallTreeNode, AftError> {
755 let canon = self.canonicalize(file)?;
756 let visit_key = (canon.clone(), symbol.to_string());
757
758 if visited.contains(&visit_key) {
760 let (line, signature) = self
761 .lookup_file_data(&canon)
762 .map(|data| get_symbol_meta_from_data(data, symbol))
763 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
764 return Ok(CallTreeNode {
765 name: symbol.to_string(),
766 file: self.relative_path(&canon),
767 line,
768 signature,
769 resolved: true,
770 children: vec![], });
772 }
773
774 visited.insert(visit_key.clone());
775
776 let (import_block, call_sites, sym_line, sym_signature) = {
777 let file_data = self.build_file(&canon)?;
778 let meta = get_symbol_meta_from_data(file_data, symbol);
779
780 (
781 file_data.import_block.clone(),
782 file_data
783 .calls_by_symbol
784 .get(symbol)
785 .cloned()
786 .unwrap_or_default(),
787 meta.0,
788 meta.1,
789 )
790 };
791
792 let mut children = Vec::new();
794
795 if current_depth < max_depth {
796 for call_site in &call_sites {
797 let edge = self.resolve_cross_file_edge(
798 &call_site.full_callee,
799 &call_site.callee_name,
800 &canon,
801 &import_block,
802 );
803
804 match edge {
805 EdgeResolution::Resolved {
806 file: ref target_file,
807 ref symbol,
808 } => {
809 match self.forward_tree_inner(
810 target_file,
811 symbol,
812 max_depth,
813 current_depth + 1,
814 visited,
815 ) {
816 Ok(child) => children.push(child),
817 Err(_) => {
818 children.push(CallTreeNode {
820 name: call_site.callee_name.clone(),
821 file: self.relative_path(target_file),
822 line: call_site.line,
823 signature: None,
824 resolved: false,
825 children: vec![],
826 });
827 }
828 }
829 }
830 EdgeResolution::Unresolved { callee_name } => {
831 if let Some(local_child) = self.resolve_local_call_tree_child(
832 &canon,
833 symbol,
834 call_site,
835 &callee_name,
836 max_depth,
837 current_depth,
838 visited,
839 )? {
840 children.push(local_child);
841 continue;
842 }
843 children.push(CallTreeNode {
844 name: callee_name,
845 file: self.relative_path(&canon),
846 line: call_site.line,
847 signature: None,
848 resolved: false,
849 children: vec![],
850 });
851 }
852 }
853 }
854 }
855
856 visited.remove(&visit_key);
857
858 Ok(CallTreeNode {
859 name: symbol.to_string(),
860 file: self.relative_path(&canon),
861 line: sym_line,
862 signature: sym_signature,
863 resolved: true,
864 children,
865 })
866 }
867
868 fn resolve_local_call_tree_child(
869 &mut self,
870 canon: &Path,
871 current_symbol: &str,
872 call_site: &CallSite,
873 callee_name: &str,
874 max_depth: usize,
875 current_depth: usize,
876 visited: &mut HashSet<(PathBuf, String)>,
877 ) -> Result<Option<CallTreeNode>, AftError> {
878 let has_local_symbol = self
879 .lookup_file_data(canon)
880 .map(|data| data.symbol_metadata.contains_key(callee_name))
881 .unwrap_or(false);
882 if !has_local_symbol {
883 return Ok(None);
884 }
885 if callee_name == current_symbol {
886 return Ok(None);
887 }
888
889 match self.forward_tree_inner(canon, callee_name, max_depth, current_depth + 1, visited) {
890 Ok(child) => Ok(Some(child)),
891 Err(_) => Ok(Some(CallTreeNode {
892 name: callee_name.to_string(),
893 file: self.relative_path(canon),
894 line: call_site.line,
895 signature: None,
896 resolved: false,
897 children: vec![],
898 })),
899 }
900 }
901
902 pub fn project_files(&mut self) -> &[PathBuf] {
904 if self.project_files.is_none() {
905 let project_root = self.project_root.clone();
906 self.project_files = Some(walk_project_files(&project_root).collect());
907 }
908 self.project_files.as_deref().unwrap_or(&[])
909 }
910
911 pub fn project_file_count(&mut self) -> usize {
917 self.project_files().len()
918 }
919
920 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
931 if let Some(files) = self.project_files.as_deref() {
932 return files.len();
933 }
934 walk_project_files(&self.project_root)
935 .take(limit.saturating_add(1))
936 .count()
937 }
938
939 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
945 let count = self.project_file_count_bounded(max_files);
950 if count > max_files {
951 return Err(AftError::ProjectTooLarge {
952 count,
953 max: max_files,
954 });
955 }
956
957 let all_files = self.project_files().to_vec();
961
962 let uncached_files: Vec<PathBuf> = all_files
964 .iter()
965 .filter(|f| self.lookup_file_data(f).is_none())
966 .cloned()
967 .collect();
968
969 let computed: Vec<(PathBuf, FileCallData)> = uncached_files
970 .par_iter()
971 .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
972 .collect();
973
974 for (file, data) in computed {
975 self.data.insert(file, data);
976 }
977
978 let mut reverse: ReverseIndex = HashMap::new();
980
981 for caller_file in &all_files {
982 let canon_caller = Arc::new(
984 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
985 );
986 let file_data = match self
987 .data
988 .get(caller_file)
989 .or_else(|| self.data.get(canon_caller.as_ref()))
990 {
991 Some(d) => d,
992 None => continue,
993 };
994
995 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
996 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
997
998 for call_site in call_sites {
999 let edge = Self::resolve_cross_file_edge_with_exports(
1000 &call_site.full_callee,
1001 &call_site.callee_name,
1002 canon_caller.as_ref(),
1003 &file_data.import_block,
1004 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1005 |path| self.file_default_export_symbol_cached(path),
1006 );
1007
1008 let (target_file, target_symbol, resolved) = match edge {
1009 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1010 EdgeResolution::Unresolved { callee_name } => {
1011 (canon_caller.as_ref().clone(), callee_name, false)
1012 }
1013 };
1014
1015 if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1016 continue;
1017 }
1018
1019 reverse
1020 .entry(target_file)
1021 .or_default()
1022 .entry(target_symbol)
1023 .or_default()
1024 .push(IndexedCallerSite {
1025 caller_file: Arc::clone(&canon_caller),
1026 caller_symbol: Arc::clone(&caller_symbol),
1027 line: call_site.line,
1028 col: 0,
1029 resolved,
1030 });
1031 }
1032 }
1033 }
1034
1035 self.reverse_index = Some(reverse);
1036 Ok(())
1037 }
1038
1039 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1040 self.reverse_index
1041 .as_ref()?
1042 .get(file)?
1043 .get(symbol)
1044 .map(Vec::as_slice)
1045 }
1046
1047 pub fn callers_of(
1053 &mut self,
1054 file: &Path,
1055 symbol: &str,
1056 depth: usize,
1057 max_files: usize,
1058 ) -> Result<CallersResult, AftError> {
1059 let canon = self.canonicalize(file)?;
1060
1061 self.build_file(&canon)?;
1063
1064 if self.reverse_index.is_none() {
1066 self.build_reverse_index(max_files)?;
1067 }
1068
1069 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1070 let effective_depth = if depth == 0 { 1 } else { depth };
1071
1072 let mut visited = HashSet::new();
1073 let mut all_sites: Vec<CallerSite> = Vec::new();
1074 self.collect_callers_recursive(
1075 &canon,
1076 symbol,
1077 effective_depth,
1078 0,
1079 &mut visited,
1080 &mut all_sites,
1081 );
1082
1083 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1086 let total_callers = all_sites.len();
1087 for site in all_sites {
1088 let caller_file: PathBuf = site.caller_file;
1089 let caller_symbol: String = site.caller_symbol;
1090 let line = site.line;
1091 let entry = CallerEntry {
1092 symbol: caller_symbol,
1093 line,
1094 };
1095
1096 if let Some(entries) = groups_map.get_mut(&caller_file) {
1097 entries.push(entry);
1098 } else {
1099 groups_map.insert(caller_file, vec![entry]);
1100 }
1101 }
1102
1103 let mut callers: Vec<CallerGroup> = groups_map
1104 .into_iter()
1105 .map(|(file_path, entries)| CallerGroup {
1106 file: self.relative_path(&file_path),
1107 callers: entries,
1108 })
1109 .collect();
1110
1111 callers.sort_by(|a, b| a.file.cmp(&b.file));
1113
1114 Ok(CallersResult {
1115 symbol: symbol.to_string(),
1116 file: self.relative_path(&canon),
1117 callers,
1118 total_callers,
1119 scanned_files,
1120 })
1121 }
1122
1123 pub fn trace_to(
1129 &mut self,
1130 file: &Path,
1131 symbol: &str,
1132 max_depth: usize,
1133 max_files: usize,
1134 ) -> Result<TraceToResult, AftError> {
1135 let canon = self.canonicalize(file)?;
1136
1137 self.build_file(&canon)?;
1139
1140 if self.reverse_index.is_none() {
1142 self.build_reverse_index(max_files)?;
1143 }
1144
1145 let target_rel = self.relative_path(&canon);
1146 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1147 if self.reverse_index.is_none() {
1148 return Err(AftError::ParseError {
1149 message: format!(
1150 "reverse index unavailable after building callers for {}",
1151 canon.display()
1152 ),
1153 });
1154 }
1155
1156 let (target_line, target_sig) = self
1158 .lookup_file_data(&canon)
1159 .map(|data| get_symbol_meta_from_data(data, symbol))
1160 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1161
1162 let target_is_entry = self
1164 .lookup_file_data(&canon)
1165 .and_then(|fd| {
1166 let meta = fd.symbol_metadata.get(symbol)?;
1167 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1168 })
1169 .unwrap_or(false);
1170
1171 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1174 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1175 let mut max_depth_reached = false;
1176 let mut truncated_paths: usize = 0;
1177
1178 let initial: Vec<PathElem> = vec![(
1180 Arc::new(canon.clone()),
1181 Arc::from(symbol),
1182 target_line,
1183 target_sig,
1184 )];
1185
1186 if target_is_entry {
1188 complete_paths.push(initial.clone());
1189 }
1190
1191 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1193
1194 while let Some((path, depth)) = queue.pop() {
1195 if depth >= effective_max {
1196 max_depth_reached = true;
1197 continue;
1198 }
1199
1200 let Some((current_file, current_symbol, _, _)) = path.last() else {
1201 continue;
1202 };
1203
1204 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1206 Some(sites) => sites,
1207 None => {
1208 if path.len() > 1 {
1211 truncated_paths += 1;
1214 }
1215 continue;
1216 }
1217 };
1218
1219 let mut has_new_path = false;
1220 for site in callers {
1221 if path.iter().any(|(file_path, sym, _, _)| {
1223 file_path.as_ref() == site.caller_file.as_ref()
1224 && sym.as_ref() == site.caller_symbol.as_ref()
1225 }) {
1226 continue;
1227 }
1228
1229 has_new_path = true;
1230
1231 let (caller_line, caller_sig) = self
1233 .lookup_file_data(site.caller_file.as_ref())
1234 .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1235 .unwrap_or_else(|| {
1236 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1237 });
1238
1239 let mut new_path = path.clone();
1240 new_path.push((
1241 Arc::clone(&site.caller_file),
1242 Arc::clone(&site.caller_symbol),
1243 caller_line,
1244 caller_sig,
1245 ));
1246
1247 let caller_is_entry = self
1251 .lookup_file_data(site.caller_file.as_ref())
1252 .and_then(|fd| {
1253 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1254 Some(is_entry_point(
1255 site.caller_symbol.as_ref(),
1256 &meta.kind,
1257 meta.exported,
1258 fd.lang,
1259 ))
1260 })
1261 .unwrap_or(false);
1262
1263 if caller_is_entry {
1264 complete_paths.push(new_path.clone());
1265 }
1266 queue.push((new_path, depth + 1));
1269 }
1270
1271 if !has_new_path && path.len() > 1 {
1273 truncated_paths += 1;
1274 }
1275 }
1276
1277 let mut paths: Vec<TracePath> = complete_paths
1280 .into_iter()
1281 .map(|mut elems| {
1282 elems.reverse();
1283 let hops: Vec<TraceHop> = elems
1284 .iter()
1285 .enumerate()
1286 .map(|(i, (file_path, sym, line, sig))| {
1287 let is_ep = if i == 0 {
1288 self.lookup_file_data(file_path.as_ref())
1290 .and_then(|fd| {
1291 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1292 Some(is_entry_point(
1293 sym.as_ref(),
1294 &meta.kind,
1295 meta.exported,
1296 fd.lang,
1297 ))
1298 })
1299 .unwrap_or(false)
1300 } else {
1301 false
1302 };
1303 TraceHop {
1304 symbol: sym.to_string(),
1305 file: self.relative_path(file_path.as_ref()),
1306 line: *line,
1307 signature: sig.clone(),
1308 is_entry_point: is_ep,
1309 }
1310 })
1311 .collect();
1312 TracePath { hops }
1313 })
1314 .collect();
1315
1316 paths.sort_by(|a, b| {
1318 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1319 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1320 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1321 });
1322
1323 let mut entry_point_names: HashSet<String> = HashSet::new();
1325 for p in &paths {
1326 if let Some(first) = p.hops.first() {
1327 if first.is_entry_point {
1328 entry_point_names.insert(first.symbol.clone());
1329 }
1330 }
1331 }
1332
1333 let total_paths = paths.len();
1334 let entry_points_found = entry_point_names.len();
1335
1336 Ok(TraceToResult {
1337 target_symbol: symbol.to_string(),
1338 target_file: target_rel,
1339 paths,
1340 total_paths,
1341 entry_points_found,
1342 max_depth_reached,
1343 truncated_paths,
1344 })
1345 }
1346
1347 pub fn impact(
1353 &mut self,
1354 file: &Path,
1355 symbol: &str,
1356 depth: usize,
1357 max_files: usize,
1358 ) -> Result<ImpactResult, AftError> {
1359 let canon = self.canonicalize(file)?;
1360
1361 self.build_file(&canon)?;
1363
1364 if self.reverse_index.is_none() {
1366 self.build_reverse_index(max_files)?;
1367 }
1368
1369 let effective_depth = if depth == 0 { 1 } else { depth };
1370
1371 let (target_signature, target_parameters, target_lang) = {
1373 let file_data = match self.data.get(&canon) {
1374 Some(d) => d,
1375 None => {
1376 return Err(AftError::InvalidRequest {
1377 message: "file data missing after build".to_string(),
1378 })
1379 }
1380 };
1381 let meta = file_data.symbol_metadata.get(symbol);
1382 let sig = meta.and_then(|m| m.signature.clone());
1383 let lang = file_data.lang;
1384 let params = sig
1385 .as_deref()
1386 .map(|s| extract_parameters(s, lang))
1387 .unwrap_or_default();
1388 (sig, params, lang)
1389 };
1390
1391 let mut visited = HashSet::new();
1393 let mut all_sites: Vec<CallerSite> = Vec::new();
1394 self.collect_callers_recursive(
1395 &canon,
1396 symbol,
1397 effective_depth,
1398 0,
1399 &mut visited,
1400 &mut all_sites,
1401 );
1402
1403 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1405 all_sites.retain(|site| {
1406 seen.insert((
1407 site.caller_file.clone(),
1408 site.caller_symbol.clone(),
1409 site.line,
1410 ))
1411 });
1412
1413 let mut callers = Vec::new();
1415 let mut affected_file_set = HashSet::new();
1416
1417 for site in &all_sites {
1418 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1420 log::debug!(
1421 "callgraph: skipping caller file {}: {}",
1422 site.caller_file.display(),
1423 e
1424 );
1425 }
1426
1427 let (sig, is_ep, params, _lang) = {
1428 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1429 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1430 let sig = meta.and_then(|m| m.signature.clone());
1431 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1432 let exported = meta.map(|m| m.exported).unwrap_or(false);
1433 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1434 let lang = fd.lang;
1435 let params = sig
1436 .as_deref()
1437 .map(|s| extract_parameters(s, lang))
1438 .unwrap_or_default();
1439 (sig, is_ep, params, lang)
1440 } else {
1441 (None, false, Vec::new(), target_lang)
1442 }
1443 };
1444
1445 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1447
1448 let rel_file = self.relative_path(site.caller_file.as_path());
1449 affected_file_set.insert(rel_file.clone());
1450
1451 callers.push(ImpactCaller {
1452 caller_symbol: site.caller_symbol.clone(),
1453 caller_file: rel_file,
1454 line: site.line,
1455 signature: sig,
1456 is_entry_point: is_ep,
1457 call_expression,
1458 parameters: params,
1459 });
1460 }
1461
1462 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1464
1465 let total_affected = callers.len();
1466 let affected_files = affected_file_set.len();
1467
1468 Ok(ImpactResult {
1469 symbol: symbol.to_string(),
1470 file: self.relative_path(&canon),
1471 signature: target_signature,
1472 parameters: target_parameters,
1473 total_affected,
1474 affected_files,
1475 callers,
1476 })
1477 }
1478
1479 pub fn trace_data(
1490 &mut self,
1491 file: &Path,
1492 symbol: &str,
1493 expression: &str,
1494 max_depth: usize,
1495 max_files: usize,
1496 ) -> Result<TraceDataResult, AftError> {
1497 let canon = self.canonicalize(file)?;
1498 let rel_file = self.relative_path(&canon);
1499
1500 self.build_file(&canon)?;
1502
1503 {
1505 let fd = match self.data.get(&canon) {
1506 Some(d) => d,
1507 None => {
1508 return Err(AftError::InvalidRequest {
1509 message: "file data missing after build".to_string(),
1510 })
1511 }
1512 };
1513 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1514 || fd.exported_symbols.iter().any(|name| name == symbol)
1515 || fd.symbol_metadata.contains_key(symbol);
1516 if !has_symbol {
1517 return Err(AftError::InvalidRequest {
1518 message: format!(
1519 "trace_data: symbol '{}' not found in {}",
1520 symbol,
1521 file.display()
1522 ),
1523 });
1524 }
1525 }
1526
1527 let count = self.project_file_count_bounded(max_files);
1531 if count > max_files {
1532 return Err(AftError::ProjectTooLarge {
1533 count,
1534 max: max_files,
1535 });
1536 }
1537
1538 let mut hops = Vec::new();
1539 let mut depth_limited = false;
1540
1541 self.trace_data_inner(
1542 &canon,
1543 symbol,
1544 expression,
1545 max_depth,
1546 0,
1547 &mut hops,
1548 &mut depth_limited,
1549 &mut HashSet::new(),
1550 );
1551
1552 Ok(TraceDataResult {
1553 expression: expression.to_string(),
1554 origin_file: rel_file,
1555 origin_symbol: symbol.to_string(),
1556 hops,
1557 depth_limited,
1558 })
1559 }
1560
1561 fn trace_data_inner(
1563 &mut self,
1564 file: &Path,
1565 symbol: &str,
1566 tracking_name: &str,
1567 max_depth: usize,
1568 current_depth: usize,
1569 hops: &mut Vec<DataFlowHop>,
1570 depth_limited: &mut bool,
1571 visited: &mut HashSet<(PathBuf, String, String)>,
1572 ) {
1573 let visit_key = (
1574 file.to_path_buf(),
1575 symbol.to_string(),
1576 tracking_name.to_string(),
1577 );
1578 if visited.contains(&visit_key) {
1579 return; }
1581 visited.insert(visit_key);
1582
1583 let source = match std::fs::read_to_string(file) {
1585 Ok(s) => s,
1586 Err(_) => return,
1587 };
1588
1589 let lang = match detect_language(file) {
1590 Some(l) => l,
1591 None => return,
1592 };
1593
1594 let grammar = grammar_for(lang);
1595 let mut parser = Parser::new();
1596 if parser.set_language(&grammar).is_err() {
1597 return;
1598 }
1599 let tree = match parser.parse(&source, None) {
1600 Some(t) => t,
1601 None => return,
1602 };
1603
1604 let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1606 Ok(symbols) => symbols,
1607 Err(_) => return,
1608 };
1609 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1610 Some(s) => s,
1611 None => return,
1612 };
1613
1614 let body_start =
1615 line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1616 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1617
1618 let root = tree.root_node();
1619
1620 let body_node = match find_node_covering_range(root, body_start, body_end) {
1622 Some(n) => n,
1623 None => return,
1624 };
1625
1626 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1628 let rel_file = self.relative_path(file);
1629
1630 self.walk_for_data_flow(
1632 body_node,
1633 &source,
1634 &mut tracked_names,
1635 file,
1636 symbol,
1637 &rel_file,
1638 lang,
1639 max_depth,
1640 current_depth,
1641 hops,
1642 depth_limited,
1643 visited,
1644 );
1645 }
1646
1647 #[allow(clippy::too_many_arguments)]
1650 fn walk_for_data_flow(
1651 &mut self,
1652 node: tree_sitter::Node,
1653 source: &str,
1654 tracked_names: &mut Vec<String>,
1655 file: &Path,
1656 symbol: &str,
1657 rel_file: &str,
1658 lang: LangId,
1659 max_depth: usize,
1660 current_depth: usize,
1661 hops: &mut Vec<DataFlowHop>,
1662 depth_limited: &mut bool,
1663 visited: &mut HashSet<(PathBuf, String, String)>,
1664 ) {
1665 let kind = node.kind();
1666
1667 let is_var_decl = matches!(
1669 kind,
1670 "variable_declarator"
1671 | "assignment_expression"
1672 | "augmented_assignment_expression"
1673 | "assignment"
1674 | "let_declaration"
1675 | "short_var_declaration"
1676 );
1677
1678 if is_var_decl {
1679 if let Some((new_name, init_text, line, is_approx)) =
1680 self.extract_assignment_info(node, source, lang, tracked_names)
1681 {
1682 if !is_approx {
1684 hops.push(DataFlowHop {
1685 file: rel_file.to_string(),
1686 symbol: symbol.to_string(),
1687 variable: new_name.clone(),
1688 line,
1689 flow_type: "assignment".to_string(),
1690 approximate: false,
1691 });
1692 tracked_names.push(new_name);
1693 } else {
1694 hops.push(DataFlowHop {
1696 file: rel_file.to_string(),
1697 symbol: symbol.to_string(),
1698 variable: init_text,
1699 line,
1700 flow_type: "assignment".to_string(),
1701 approximate: true,
1702 });
1703 return;
1705 }
1706 }
1707 }
1708
1709 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1711 self.check_call_for_data_flow(
1712 node,
1713 source,
1714 tracked_names,
1715 file,
1716 symbol,
1717 rel_file,
1718 lang,
1719 max_depth,
1720 current_depth,
1721 hops,
1722 depth_limited,
1723 visited,
1724 );
1725 }
1726
1727 let mut cursor = node.walk();
1729 if cursor.goto_first_child() {
1730 loop {
1731 let child = cursor.node();
1732 self.walk_for_data_flow(
1734 child,
1735 source,
1736 tracked_names,
1737 file,
1738 symbol,
1739 rel_file,
1740 lang,
1741 max_depth,
1742 current_depth,
1743 hops,
1744 depth_limited,
1745 visited,
1746 );
1747 if !cursor.goto_next_sibling() {
1748 break;
1749 }
1750 }
1751 }
1752 }
1753
1754 fn extract_assignment_info(
1757 &self,
1758 node: tree_sitter::Node,
1759 source: &str,
1760 _lang: LangId,
1761 tracked_names: &[String],
1762 ) -> Option<(String, String, u32, bool)> {
1763 let kind = node.kind();
1764 let line = node.start_position().row as u32 + 1;
1765
1766 match kind {
1767 "variable_declarator" => {
1768 let name_node = node.child_by_field_name("name")?;
1770 let value_node = node.child_by_field_name("value")?;
1771 let name_text = node_text(name_node, source);
1772 let value_text = node_text(value_node, source);
1773
1774 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1776 if tracked_names.iter().any(|t| value_text.contains(t)) {
1778 return Some((name_text.clone(), name_text, line, true));
1779 }
1780 return None;
1781 }
1782
1783 if tracked_names.iter().any(|t| {
1785 value_text == *t
1786 || value_text.starts_with(&format!("{}.", t))
1787 || value_text.starts_with(&format!("{}[", t))
1788 }) {
1789 return Some((name_text, value_text, line, false));
1790 }
1791 None
1792 }
1793 "assignment_expression" | "augmented_assignment_expression" => {
1794 let left = node.child_by_field_name("left")?;
1796 let right = node.child_by_field_name("right")?;
1797 let left_text = node_text(left, source);
1798 let right_text = node_text(right, source);
1799
1800 if tracked_names.iter().any(|t| right_text == *t) {
1801 return Some((left_text, right_text, line, false));
1802 }
1803 None
1804 }
1805 "assignment" => {
1806 let left = node.child_by_field_name("left")?;
1808 let right = node.child_by_field_name("right")?;
1809 let left_text = node_text(left, source);
1810 let right_text = node_text(right, source);
1811
1812 if tracked_names.iter().any(|t| right_text == *t) {
1813 return Some((left_text, right_text, line, false));
1814 }
1815 None
1816 }
1817 "let_declaration" | "short_var_declaration" => {
1818 let left = node
1820 .child_by_field_name("pattern")
1821 .or_else(|| node.child_by_field_name("left"))?;
1822 let right = node
1823 .child_by_field_name("value")
1824 .or_else(|| node.child_by_field_name("right"))?;
1825 let left_text = node_text(left, source);
1826 let right_text = node_text(right, source);
1827
1828 if tracked_names.iter().any(|t| right_text == *t) {
1829 return Some((left_text, right_text, line, false));
1830 }
1831 None
1832 }
1833 _ => None,
1834 }
1835 }
1836
1837 #[allow(clippy::too_many_arguments)]
1840 fn check_call_for_data_flow(
1841 &mut self,
1842 node: tree_sitter::Node,
1843 source: &str,
1844 tracked_names: &[String],
1845 file: &Path,
1846 _symbol: &str,
1847 rel_file: &str,
1848 _lang: LangId,
1849 max_depth: usize,
1850 current_depth: usize,
1851 hops: &mut Vec<DataFlowHop>,
1852 depth_limited: &mut bool,
1853 visited: &mut HashSet<(PathBuf, String, String)>,
1854 ) {
1855 let args_node = find_child_by_kind(node, "arguments")
1857 .or_else(|| find_child_by_kind(node, "argument_list"));
1858
1859 let args_node = match args_node {
1860 Some(n) => n,
1861 None => return,
1862 };
1863
1864 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1867
1868 let mut cursor = args_node.walk();
1869 if cursor.goto_first_child() {
1870 loop {
1871 let child = cursor.node();
1872 let child_kind = child.kind();
1873
1874 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1876 if !cursor.goto_next_sibling() {
1877 break;
1878 }
1879 continue;
1880 }
1881
1882 let arg_text = node_text(child, source);
1883
1884 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1886 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1887 hops.push(DataFlowHop {
1888 file: rel_file.to_string(),
1889 symbol: _symbol.to_string(),
1890 variable: arg_text,
1891 line: child.start_position().row as u32 + 1,
1892 flow_type: "parameter".to_string(),
1893 approximate: true,
1894 });
1895 }
1896 if !cursor.goto_next_sibling() {
1897 break;
1898 }
1899 arg_idx += 1;
1900 continue;
1901 }
1902
1903 if tracked_names.iter().any(|t| arg_text == *t) {
1904 arg_positions.push((arg_idx, arg_text));
1905 }
1906
1907 arg_idx += 1;
1908 if !cursor.goto_next_sibling() {
1909 break;
1910 }
1911 }
1912 }
1913
1914 if arg_positions.is_empty() {
1915 return;
1916 }
1917
1918 let (full_callee, short_callee) = extract_callee_names(node, source);
1920 let full_callee = match full_callee {
1921 Some(f) => f,
1922 None => return,
1923 };
1924 let short_callee = match short_callee {
1925 Some(s) => s,
1926 None => return,
1927 };
1928
1929 let import_block = {
1931 match self.data.get(file) {
1932 Some(fd) => fd.import_block.clone(),
1933 None => return,
1934 }
1935 };
1936
1937 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1938
1939 match edge {
1940 EdgeResolution::Resolved {
1941 file: target_file,
1942 symbol: target_symbol,
1943 } => {
1944 if current_depth + 1 > max_depth {
1945 *depth_limited = true;
1946 return;
1947 }
1948
1949 if let Err(e) = self.build_file(&target_file) {
1951 log::debug!(
1952 "callgraph: skipping target file {}: {}",
1953 target_file.display(),
1954 e
1955 );
1956 }
1957 let (params, target_line) = {
1958 match self.lookup_file_data(&target_file) {
1959 Some(fd) => {
1960 let meta = fd.symbol_metadata.get(&target_symbol);
1961 let sig = meta.and_then(|m| m.signature.clone());
1962 let params = sig
1963 .as_deref()
1964 .map(|s| extract_parameters(s, fd.lang))
1965 .unwrap_or_default();
1966 let line = meta.map(|m| m.line).unwrap_or(1);
1967 (params, line)
1968 }
1969 None => return,
1970 }
1971 };
1972
1973 let target_rel = self.relative_path(&target_file);
1974
1975 for (pos, _tracked) in &arg_positions {
1976 if let Some(param_name) = params.get(*pos) {
1977 hops.push(DataFlowHop {
1979 file: target_rel.clone(),
1980 symbol: target_symbol.clone(),
1981 variable: param_name.clone(),
1982 line: target_line,
1983 flow_type: "parameter".to_string(),
1984 approximate: false,
1985 });
1986
1987 self.trace_data_inner(
1989 &target_file.clone(),
1990 &target_symbol.clone(),
1991 param_name,
1992 max_depth,
1993 current_depth + 1,
1994 hops,
1995 depth_limited,
1996 visited,
1997 );
1998 }
1999 }
2000 }
2001 EdgeResolution::Unresolved { callee_name } => {
2002 let has_local = self
2004 .data
2005 .get(file)
2006 .map(|fd| {
2007 fd.calls_by_symbol.contains_key(&callee_name)
2008 || fd.symbol_metadata.contains_key(&callee_name)
2009 })
2010 .unwrap_or(false);
2011
2012 if has_local {
2013 let (params, target_line) = {
2015 let Some(fd) = self.data.get(file) else {
2016 return;
2017 };
2018 let meta = fd.symbol_metadata.get(&callee_name);
2019 let sig = meta.and_then(|m| m.signature.clone());
2020 let params = sig
2021 .as_deref()
2022 .map(|s| extract_parameters(s, fd.lang))
2023 .unwrap_or_default();
2024 let line = meta.map(|m| m.line).unwrap_or(1);
2025 (params, line)
2026 };
2027
2028 let file_rel = self.relative_path(file);
2029
2030 for (pos, _tracked) in &arg_positions {
2031 if let Some(param_name) = params.get(*pos) {
2032 hops.push(DataFlowHop {
2033 file: file_rel.clone(),
2034 symbol: callee_name.clone(),
2035 variable: param_name.clone(),
2036 line: target_line,
2037 flow_type: "parameter".to_string(),
2038 approximate: false,
2039 });
2040
2041 self.trace_data_inner(
2043 file,
2044 &callee_name.clone(),
2045 param_name,
2046 max_depth,
2047 current_depth + 1,
2048 hops,
2049 depth_limited,
2050 visited,
2051 );
2052 }
2053 }
2054 } else {
2055 for (_pos, tracked) in &arg_positions {
2057 hops.push(DataFlowHop {
2058 file: self.relative_path(file),
2059 symbol: callee_name.clone(),
2060 variable: tracked.clone(),
2061 line: node.start_position().row as u32 + 1,
2062 flow_type: "parameter".to_string(),
2063 approximate: true,
2064 });
2065 }
2066 }
2067 }
2068 }
2069 }
2070
2071 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2073 let content = std::fs::read_to_string(path).ok()?;
2074 content
2075 .lines()
2076 .nth(line.saturating_sub(1) as usize)
2077 .map(|l| l.trim().to_string())
2078 }
2079
2080 fn collect_callers_recursive(
2082 &self,
2083 file: &Path,
2084 symbol: &str,
2085 max_depth: usize,
2086 current_depth: usize,
2087 visited: &mut HashSet<(PathBuf, SharedStr)>,
2088 result: &mut Vec<CallerSite>,
2089 ) {
2090 if current_depth >= max_depth {
2091 return;
2092 }
2093
2094 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2096 let key_symbol: SharedStr = Arc::from(symbol);
2097 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2098 return; }
2100
2101 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2102 for site in sites {
2103 result.push(CallerSite {
2104 caller_file: site.caller_file.as_ref().clone(),
2105 caller_symbol: site.caller_symbol.to_string(),
2106 line: site.line,
2107 col: site.col,
2108 resolved: site.resolved,
2109 });
2110 if current_depth + 1 < max_depth {
2112 self.collect_callers_recursive(
2113 site.caller_file.as_ref(),
2114 site.caller_symbol.as_ref(),
2115 max_depth,
2116 current_depth + 1,
2117 visited,
2118 result,
2119 );
2120 }
2121 }
2122 }
2123 }
2124
2125 pub fn invalidate_file(&mut self, path: &Path) {
2130 self.data.remove(path);
2132 if let Ok(canon) = self.canonicalize(path) {
2133 self.data.remove(&canon);
2134 }
2135 self.reverse_index = None;
2137 self.project_files = None;
2139 clear_workspace_package_cache();
2140 }
2141
2142 fn relative_path(&self, path: &Path) -> String {
2145 path.strip_prefix(&self.project_root)
2146 .unwrap_or(path)
2147 .display()
2148 .to_string()
2149 }
2150
2151 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2153 let full_path = if path.is_relative() {
2155 self.project_root.join(path)
2156 } else {
2157 path.to_path_buf()
2158 };
2159
2160 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2162 }
2163
2164 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2168 if let Some(fd) = self.data.get(path) {
2169 return Some(fd);
2170 }
2171 let canon = std::fs::canonicalize(path).ok()?;
2173 self.data.get(&canon).or_else(|| {
2174 self.data.iter().find_map(|(k, v)| {
2176 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2177 Some(v)
2178 } else {
2179 None
2180 }
2181 })
2182 })
2183 }
2184}
2185
2186fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2192 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2193 message: format!("unsupported file for call graph: {}", path.display()),
2194 })?;
2195
2196 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2197 path: format!("{}: {}", path.display(), e),
2198 })?;
2199
2200 let grammar = grammar_for(lang);
2201 let mut parser = Parser::new();
2202 parser
2203 .set_language(&grammar)
2204 .map_err(|e| AftError::ParseError {
2205 message: format!("grammar init failed for {:?}: {}", lang, e),
2206 })?;
2207
2208 let tree = parser
2209 .parse(&source, None)
2210 .ok_or_else(|| AftError::ParseError {
2211 message: format!("parse failed for {}", path.display()),
2212 })?;
2213
2214 let import_block = imports::parse_imports(&source, &tree, lang);
2216
2217 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2219
2220 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2222 let root = tree.root_node();
2223
2224 for sym in &symbols {
2225 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2226 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2227
2228 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2229
2230 let sites: Vec<CallSite> = raw_calls
2231 .into_iter()
2232 .map(|(full, short, line)| CallSite {
2233 callee_name: short,
2234 full_callee: full,
2235 line,
2236 byte_start,
2237 byte_end,
2238 })
2239 .collect();
2240
2241 if !sites.is_empty() {
2242 calls_by_symbol.insert(sym.name.clone(), sites);
2243 }
2244 }
2245
2246 let symbol_ranges: Vec<(usize, usize)> = symbols
2247 .iter()
2248 .map(|sym| {
2249 (
2250 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2251 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2252 )
2253 })
2254 .collect();
2255
2256 let top_level_sites: Vec<CallSite> =
2257 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2258 .into_iter()
2259 .filter(|site| {
2260 !symbol_ranges
2261 .iter()
2262 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2263 })
2264 .map(|site| CallSite {
2265 callee_name: site.short,
2266 full_callee: site.full,
2267 line: site.line,
2268 byte_start: site.byte_start,
2269 byte_end: site.byte_end,
2270 })
2271 .collect();
2272
2273 if !top_level_sites.is_empty() {
2274 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2275 }
2276
2277 let default_export = find_default_export(&source, root, path, lang);
2278
2279 if let Some(default_export) = &default_export {
2280 if default_export.synthetic {
2281 let byte_start = default_export.node.byte_range().start;
2282 let byte_end = default_export.node.byte_range().end;
2283 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2284 let sites: Vec<CallSite> = raw_calls
2285 .into_iter()
2286 .filter(|(_, short, _)| *short != default_export.symbol)
2287 .map(|(full, short, line)| CallSite {
2288 callee_name: short,
2289 full_callee: full,
2290 line,
2291 byte_start,
2292 byte_end,
2293 })
2294 .collect();
2295 if !sites.is_empty() {
2296 calls_by_symbol.insert(default_export.symbol.clone(), sites);
2297 }
2298 }
2299 }
2300
2301 let mut exported_symbols: Vec<String> = symbols
2303 .iter()
2304 .filter(|s| s.exported)
2305 .map(|s| s.name.clone())
2306 .collect();
2307 if let Some(default_export) = &default_export {
2308 if !exported_symbols
2309 .iter()
2310 .any(|name| name == &default_export.symbol)
2311 {
2312 exported_symbols.push(default_export.symbol.clone());
2313 }
2314 }
2315
2316 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2318 .iter()
2319 .map(|s| {
2320 (
2321 s.name.clone(),
2322 SymbolMeta {
2323 kind: s.kind.clone(),
2324 exported: s.exported,
2325 signature: s.signature.clone(),
2326 line: s.range.start_line + 1,
2327 range: s.range.clone(),
2328 },
2329 )
2330 })
2331 .collect();
2332 if let Some(default_export) = &default_export {
2333 symbol_metadata
2334 .entry(default_export.symbol.clone())
2335 .or_insert_with(|| SymbolMeta {
2336 kind: default_export.kind.clone(),
2337 exported: true,
2338 signature: Some(first_line_signature(&source, &default_export.node)),
2339 line: default_export.node.start_position().row as u32 + 1,
2340 range: crate::parser::node_range(&default_export.node),
2341 });
2342 }
2343 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2344 symbol_metadata
2345 .entry(TOP_LEVEL_SYMBOL.to_string())
2346 .or_insert(SymbolMeta {
2347 kind: SymbolKind::Function,
2348 exported: false,
2349 signature: None,
2350 line: 1,
2351 range: Range {
2352 start_line: 0,
2353 start_col: 0,
2354 end_line: 0,
2355 end_col: 0,
2356 },
2357 });
2358 }
2359
2360 Ok(FileCallData {
2361 calls_by_symbol,
2362 exported_symbols,
2363 symbol_metadata,
2364 default_export_symbol: default_export.map(|export| export.symbol),
2365 import_block,
2366 lang,
2367 })
2368}
2369
2370#[derive(Debug, Clone)]
2371struct DefaultExport<'tree> {
2372 symbol: String,
2373 synthetic: bool,
2374 kind: SymbolKind,
2375 node: Node<'tree>,
2376}
2377
2378fn find_default_export<'tree>(
2379 source: &str,
2380 root: Node<'tree>,
2381 path: &Path,
2382 lang: LangId,
2383) -> Option<DefaultExport<'tree>> {
2384 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2385 return None;
2386 }
2387 find_default_export_inner(source, root, path)
2388}
2389
2390fn find_default_export_inner<'tree>(
2391 source: &str,
2392 node: Node<'tree>,
2393 path: &Path,
2394) -> Option<DefaultExport<'tree>> {
2395 if node.kind() == "export_statement" {
2396 if let Some(default_export) = default_export_from_statement(source, node, path) {
2397 return Some(default_export);
2398 }
2399 }
2400
2401 let mut cursor = node.walk();
2402 if !cursor.goto_first_child() {
2403 return None;
2404 }
2405
2406 loop {
2407 let child = cursor.node();
2408 if let Some(default_export) = find_default_export_inner(source, child, path) {
2409 return Some(default_export);
2410 }
2411 if !cursor.goto_next_sibling() {
2412 break;
2413 }
2414 }
2415
2416 None
2417}
2418
2419fn default_export_from_statement<'tree>(
2420 source: &str,
2421 node: Node<'tree>,
2422 path: &Path,
2423) -> Option<DefaultExport<'tree>> {
2424 let mut cursor = node.walk();
2425 if !cursor.goto_first_child() {
2426 return None;
2427 }
2428
2429 let mut saw_default = false;
2430 loop {
2431 let child = cursor.node();
2432 match child.kind() {
2433 "default" => saw_default = true,
2434 "function_declaration" | "generator_function_declaration" | "class_declaration"
2435 if saw_default =>
2436 {
2437 if let Some(name_node) = child.child_by_field_name("name") {
2438 return Some(DefaultExport {
2439 symbol: source[name_node.byte_range()].to_string(),
2440 synthetic: false,
2441 kind: default_export_kind(&child),
2442 node: child,
2443 });
2444 }
2445 return Some(DefaultExport {
2446 symbol: synthetic_default_symbol(path),
2447 synthetic: true,
2448 kind: default_export_kind(&child),
2449 node: child,
2450 });
2451 }
2452 "arrow_function"
2453 | "function"
2454 | "function_expression"
2455 | "class"
2456 | "class_expression"
2457 if saw_default =>
2458 {
2459 return Some(DefaultExport {
2460 symbol: synthetic_default_symbol(path),
2461 synthetic: true,
2462 kind: default_export_kind(&child),
2463 node: child,
2464 });
2465 }
2466 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
2467 return Some(DefaultExport {
2468 symbol: source[child.byte_range()].to_string(),
2469 synthetic: false,
2470 kind: SymbolKind::Function,
2471 node: child,
2472 });
2473 }
2474 _ => {}
2475 }
2476 if !cursor.goto_next_sibling() {
2477 break;
2478 }
2479 }
2480
2481 None
2482}
2483
2484fn default_export_kind(node: &Node) -> SymbolKind {
2485 if node.kind().contains("class") {
2486 SymbolKind::Class
2487 } else {
2488 SymbolKind::Function
2489 }
2490}
2491
2492fn synthetic_default_symbol(path: &Path) -> String {
2493 let file_name = path
2494 .file_name()
2495 .and_then(|name| name.to_str())
2496 .unwrap_or("unknown");
2497 format!("<default:{file_name}>")
2498}
2499
2500fn first_line_signature(source: &str, node: &Node) -> String {
2501 let text = &source[node.byte_range()];
2502 let first_line = text.lines().next().unwrap_or(text);
2503 first_line
2504 .trim_end()
2505 .trim_end_matches('{')
2506 .trim_end()
2507 .to_string()
2508}
2509
2510fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2511 file_data
2512 .symbol_metadata
2513 .get(symbol_name)
2514 .map(|meta| (meta.line, meta.signature.clone()))
2515 .unwrap_or((1, None))
2516}
2517
2518fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2520 let provider = crate::parser::TreeSitterProvider::new();
2521 match provider.list_symbols(path) {
2522 Ok(symbols) => {
2523 for s in &symbols {
2524 if s.name == symbol_name {
2525 return (s.range.start_line + 1, s.signature.clone());
2526 }
2527 }
2528 (1, None)
2529 }
2530 Err(_) => (1, None),
2531 }
2532}
2533
2534fn node_text(node: tree_sitter::Node, source: &str) -> String {
2540 source[node.start_byte()..node.end_byte()].to_string()
2541}
2542
2543fn find_node_covering_range(
2545 root: tree_sitter::Node,
2546 start: usize,
2547 end: usize,
2548) -> Option<tree_sitter::Node> {
2549 let mut best = None;
2550 let mut cursor = root.walk();
2551
2552 fn walk_covering<'a>(
2553 cursor: &mut tree_sitter::TreeCursor<'a>,
2554 start: usize,
2555 end: usize,
2556 best: &mut Option<tree_sitter::Node<'a>>,
2557 ) {
2558 let node = cursor.node();
2559 if node.start_byte() <= start && node.end_byte() >= end {
2560 *best = Some(node);
2561 if cursor.goto_first_child() {
2562 loop {
2563 walk_covering(cursor, start, end, best);
2564 if !cursor.goto_next_sibling() {
2565 break;
2566 }
2567 }
2568 cursor.goto_parent();
2569 }
2570 }
2571 }
2572
2573 walk_covering(&mut cursor, start, end, &mut best);
2574 best
2575}
2576
2577fn find_child_by_kind<'a>(
2579 node: tree_sitter::Node<'a>,
2580 kind: &str,
2581) -> Option<tree_sitter::Node<'a>> {
2582 let mut cursor = node.walk();
2583 if cursor.goto_first_child() {
2584 loop {
2585 if cursor.node().kind() == kind {
2586 return Some(cursor.node());
2587 }
2588 if !cursor.goto_next_sibling() {
2589 break;
2590 }
2591 }
2592 }
2593 None
2594}
2595
2596#[derive(Debug, Clone)]
2597struct CallSiteWithRange {
2598 full: String,
2599 short: String,
2600 line: u32,
2601 byte_start: usize,
2602 byte_end: usize,
2603}
2604
2605fn collect_calls_full_with_ranges(
2606 root: tree_sitter::Node,
2607 source: &str,
2608 byte_start: usize,
2609 byte_end: usize,
2610 lang: LangId,
2611) -> Vec<CallSiteWithRange> {
2612 let mut results = Vec::new();
2613 let call_kinds = call_node_kinds(lang);
2614 collect_calls_full_with_ranges_inner(
2615 root,
2616 source,
2617 byte_start,
2618 byte_end,
2619 &call_kinds,
2620 &mut results,
2621 );
2622 results
2623}
2624
2625fn collect_calls_full_with_ranges_inner(
2626 node: tree_sitter::Node,
2627 source: &str,
2628 byte_start: usize,
2629 byte_end: usize,
2630 call_kinds: &[&str],
2631 results: &mut Vec<CallSiteWithRange>,
2632) {
2633 let node_start = node.start_byte();
2634 let node_end = node.end_byte();
2635
2636 if node_end <= byte_start || node_start >= byte_end {
2637 return;
2638 }
2639
2640 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
2641 if let (Some(full), Some(short)) = (
2642 extract_full_callee(&node, source),
2643 extract_callee_name(&node, source),
2644 ) {
2645 results.push(CallSiteWithRange {
2646 full,
2647 short,
2648 line: node.start_position().row as u32 + 1,
2649 byte_start: node_start,
2650 byte_end: node_end,
2651 });
2652 }
2653 }
2654
2655 let mut cursor = node.walk();
2656 if cursor.goto_first_child() {
2657 loop {
2658 collect_calls_full_with_ranges_inner(
2659 cursor.node(),
2660 source,
2661 byte_start,
2662 byte_end,
2663 call_kinds,
2664 results,
2665 );
2666 if !cursor.goto_next_sibling() {
2667 break;
2668 }
2669 }
2670 }
2671}
2672
2673fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2675 let callee = match node.child_by_field_name("function") {
2677 Some(c) => c,
2678 None => return (None, None),
2679 };
2680
2681 let full = node_text(callee, source);
2682 let short = if full.contains('.') {
2683 full.rsplit('.').next().unwrap_or(&full).to_string()
2684 } else {
2685 full.clone()
2686 };
2687
2688 (Some(full), Some(short))
2689}
2690
2691pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2699 if module_path.starts_with('.') {
2700 return resolve_relative_module_path(from_dir, module_path);
2701 }
2702
2703 if module_path.starts_with('/') {
2704 return None;
2705 }
2706
2707 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
2708 return Some(path);
2709 }
2710
2711 resolve_workspace_module_path(from_dir, module_path)
2712}
2713
2714fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2715 let base = from_dir.join(module_path);
2716 resolve_file_like_path(&base)
2717}
2718
2719fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
2720 let base = base.to_path_buf();
2721
2722 if base.is_file() {
2724 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2725 }
2726
2727 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2729 for ext in &extensions {
2730 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2731 if with_ext.is_file() {
2732 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2733 }
2734 }
2735
2736 if base.is_dir() {
2738 if let Some(index) = find_index_file(&base) {
2739 return Some(index);
2740 }
2741 }
2742
2743 None
2744}
2745
2746fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2747 let (package_name, subpath) = split_package_import(module_path)?;
2748 let package_root = find_package_root_for_import(from_dir, &package_name)?;
2749 resolve_package_entry(&package_root, &subpath)
2750}
2751
2752fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2753 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2754 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2755 let compiler_options = tsconfig.get("compilerOptions")?;
2756 let paths = compiler_options.get("paths")?.as_object()?;
2757 let base_url = compiler_options
2758 .get("baseUrl")
2759 .and_then(Value::as_str)
2760 .unwrap_or(".");
2761 let base_dir = tsconfig_dir.join(base_url);
2762
2763 for (alias, targets) in paths {
2764 let Some(capture) = ts_path_capture(alias, module_path) else {
2765 continue;
2766 };
2767 let Some(targets) = targets.as_array() else {
2768 continue;
2769 };
2770 for target in targets.iter().filter_map(Value::as_str) {
2771 let target = if target.contains('*') {
2772 target.replace('*', capture)
2773 } else {
2774 target.to_string()
2775 };
2776 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2777 return Some(path);
2778 }
2779 }
2780 }
2781
2782 None
2783}
2784
2785fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2786 let mut current = Some(from_dir);
2787 while let Some(dir) = current {
2788 if dir.join("tsconfig.json").is_file() {
2789 return Some(dir.to_path_buf());
2790 }
2791 current = dir.parent();
2792 }
2793 None
2794}
2795
2796fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2797 if let Some(star_index) = alias.find('*') {
2798 let (prefix, suffix_with_star) = alias.split_at(star_index);
2799 let suffix = &suffix_with_star[1..];
2800 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2801 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2802 }
2803 return None;
2804 }
2805
2806 (alias == module_path).then_some("")
2807}
2808
2809fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2810 let mut parts = module_path.split('/');
2811 let first = parts.next()?;
2812 if first.is_empty() {
2813 return None;
2814 }
2815
2816 if first.starts_with('@') {
2817 let second = parts.next()?;
2818 if second.is_empty() {
2819 return None;
2820 }
2821 let package_name = format!("{first}/{second}");
2822 let subpath = parts.collect::<Vec<_>>().join("/");
2823 let subpath = (!subpath.is_empty()).then_some(subpath);
2824 Some((package_name, subpath))
2825 } else {
2826 let package_name = first.to_string();
2827 let subpath = parts.collect::<Vec<_>>().join("/");
2828 let subpath = (!subpath.is_empty()).then_some(subpath);
2829 Some((package_name, subpath))
2830 }
2831}
2832
2833fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2834 let mut current = Some(from_dir);
2835 while let Some(dir) = current {
2836 if package_json_name(dir).as_deref() == Some(package_name) {
2837 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2838 }
2839 current = dir.parent();
2840 }
2841
2842 find_workspace_root(from_dir)
2843 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2844}
2845
2846fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2847 let mut current = Some(from_dir);
2848 while let Some(dir) = current {
2849 if is_workspace_root(dir) {
2850 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2851 }
2852 current = dir.parent();
2853 }
2854 None
2855}
2856
2857fn is_workspace_root(dir: &Path) -> bool {
2858 package_json_value(dir)
2859 .map(|value| !workspace_patterns(&value).is_empty())
2860 .unwrap_or(false)
2861 || !pnpm_workspace_patterns(dir).is_empty()
2862}
2863
2864fn clear_workspace_package_cache() {
2865 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2866 cache.clear();
2867 }
2868}
2869
2870fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2871 let workspace_root =
2872 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2873 let cache_key = (workspace_root.clone(), package_name.to_string());
2874
2875 if let Some(cached) = WORKSPACE_PACKAGE_CACHE
2876 .read()
2877 .ok()
2878 .and_then(|cache| cache.get(&cache_key).cloned())
2879 {
2880 return cached;
2881 }
2882
2883 let resolved = workspace_member_dirs(&workspace_root)
2884 .into_iter()
2885 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2886 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2887
2888 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2889 cache.insert(cache_key, resolved.clone());
2890 }
2891
2892 resolved
2893}
2894
2895fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2896 let mut patterns = package_json_value(workspace_root)
2897 .map(|package_json| workspace_patterns(&package_json))
2898 .unwrap_or_default();
2899 patterns.extend(pnpm_workspace_patterns(workspace_root));
2900
2901 expand_workspace_patterns(workspace_root, &patterns)
2902}
2903
2904fn workspace_patterns(package_json: &Value) -> Vec<String> {
2905 match package_json.get("workspaces") {
2906 Some(Value::Array(items)) => items
2907 .iter()
2908 .filter_map(non_empty_workspace_pattern)
2909 .collect(),
2910 Some(Value::Object(map)) => map
2911 .get("packages")
2912 .and_then(Value::as_array)
2913 .map(|items| {
2914 items
2915 .iter()
2916 .filter_map(non_empty_workspace_pattern)
2917 .collect()
2918 })
2919 .unwrap_or_default(),
2920 _ => Vec::new(),
2921 }
2922}
2923
2924fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2925 let pattern = value.as_str()?.trim();
2926 (!pattern.is_empty()).then(|| pattern.to_string())
2927}
2928
2929fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2930 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2931 return Vec::new();
2932 };
2933
2934 let mut patterns = Vec::new();
2935 let mut in_packages = false;
2936 for line in source.lines() {
2937 let without_comment = line.split('#').next().unwrap_or("").trim_end();
2938 let trimmed = without_comment.trim();
2939 if trimmed.is_empty() {
2940 continue;
2941 }
2942 if trimmed == "packages:" {
2943 in_packages = true;
2944 continue;
2945 }
2946 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2947 in_packages = false;
2948 }
2949 if in_packages {
2950 if let Some(pattern) = trimmed.strip_prefix('-') {
2951 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2952 if !pattern.is_empty() {
2953 patterns.push(pattern.to_string());
2954 }
2955 }
2956 }
2957 }
2958 patterns
2959}
2960
2961fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2962 let positive_patterns: Vec<&str> = patterns
2963 .iter()
2964 .map(|pattern| pattern.trim())
2965 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2966 .collect();
2967 if positive_patterns.is_empty() {
2968 return Vec::new();
2969 }
2970
2971 let positives = build_glob_set(&positive_patterns);
2972 let negative_patterns: Vec<&str> = patterns
2973 .iter()
2974 .map(|pattern| pattern.trim())
2975 .filter_map(|pattern| pattern.strip_prefix('!'))
2976 .map(str::trim)
2977 .filter(|pattern| !pattern.is_empty())
2978 .collect();
2979 let negatives = build_glob_set(&negative_patterns);
2980
2981 let mut members = Vec::new();
2982 collect_workspace_member_dirs(
2983 workspace_root,
2984 workspace_root,
2985 &positives,
2986 &negatives,
2987 &mut members,
2988 );
2989 members
2990}
2991
2992fn build_glob_set(patterns: &[&str]) -> GlobSet {
2993 let mut builder = GlobSetBuilder::new();
2994 for pattern in patterns {
2995 if let Ok(glob) = Glob::new(pattern) {
2996 builder.add(glob);
2997 }
2998 }
2999 builder
3000 .build()
3001 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
3002}
3003
3004fn collect_workspace_member_dirs(
3005 workspace_root: &Path,
3006 dir: &Path,
3007 positives: &GlobSet,
3008 negatives: &GlobSet,
3009 members: &mut Vec<PathBuf>,
3010) {
3011 let Ok(entries) = std::fs::read_dir(dir) else {
3012 return;
3013 };
3014
3015 for entry in entries.filter_map(Result::ok) {
3016 let path = entry.path();
3017 let Ok(file_type) = entry.file_type() else {
3018 continue;
3019 };
3020 if !file_type.is_dir() {
3021 continue;
3022 }
3023 let name = entry.file_name();
3024 let name = name.to_string_lossy();
3025 if matches!(
3026 name.as_ref(),
3027 "node_modules" | ".git" | "target" | "dist" | "build"
3028 ) {
3029 continue;
3030 }
3031
3032 if path.join("package.json").is_file() {
3033 if let Ok(rel) = path.strip_prefix(workspace_root) {
3034 let rel = rel.to_string_lossy().replace('\\', "/");
3035 if positives.is_match(&rel) && !negatives.is_match(&rel) {
3036 members.push(path.clone());
3037 }
3038 }
3039 }
3040
3041 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
3042 }
3043}
3044
3045fn package_json_value(dir: &Path) -> Option<Value> {
3046 package_json_like_value(&dir.join("package.json"))
3047}
3048
3049fn package_json_like_value(path: &Path) -> Option<Value> {
3050 let json = std::fs::read_to_string(path).ok()?;
3051 serde_json::from_str(&json).ok()
3052}
3053
3054fn package_json_name(dir: &Path) -> Option<String> {
3055 package_json_value(dir)?
3056 .get("name")?
3057 .as_str()
3058 .map(ToOwned::to_owned)
3059}
3060
3061fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
3062 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
3063
3064 if let Some(exports) = package_json.get("exports") {
3065 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
3066 if let Some(path) = resolve_package_target(package_root, &target) {
3067 return Some(path);
3068 }
3069 }
3070 }
3071
3072 if subpath.is_none() {
3073 for field in ["module", "main"] {
3074 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
3075 if let Some(path) = resolve_package_target(package_root, target) {
3076 return Some(path);
3077 }
3078 }
3079 }
3080 }
3081
3082 resolve_package_fallback(package_root, subpath.as_deref())
3083}
3084
3085fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
3086 let key = subpath
3087 .map(|value| format!("./{value}"))
3088 .unwrap_or_else(|| ".".to_string());
3089
3090 match exports {
3091 Value::String(target) if key == "." => Some(target.clone()),
3092 Value::Object(map) => {
3093 if let Some(target) = map.get(&key).and_then(export_condition_target) {
3094 return Some(target);
3095 }
3096
3097 if let Some(target) = wildcard_export_target(map, &key) {
3098 return Some(target);
3099 }
3100
3101 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
3102 return export_condition_target(exports);
3103 }
3104
3105 None
3106 }
3107 _ => None,
3108 }
3109}
3110
3111fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
3112 for (pattern, target) in map {
3113 let Some(star_index) = pattern.find('*') else {
3114 continue;
3115 };
3116 let (prefix, suffix_with_star) = pattern.split_at(star_index);
3117 let suffix = &suffix_with_star[1..];
3118 if !key.starts_with(prefix) || !key.ends_with(suffix) {
3119 continue;
3120 }
3121 let matched = &key[prefix.len()..key.len() - suffix.len()];
3122 if let Some(target_pattern) = export_condition_target(target) {
3123 return Some(target_pattern.replace('*', matched));
3124 }
3125 }
3126 None
3127}
3128
3129fn export_condition_target(value: &Value) -> Option<String> {
3130 match value {
3131 Value::String(target) => Some(target.clone()),
3132 Value::Object(map) => ["source", "import", "module", "default", "types"]
3133 .into_iter()
3134 .find_map(|field| map.get(field).and_then(export_condition_target)),
3135 _ => None,
3136 }
3137}
3138
3139fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
3140 let target = target.strip_prefix("./").unwrap_or(target);
3141 if let Some(src_relative) = target.strip_prefix("dist/") {
3144 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
3145 return Some(path);
3146 }
3147 }
3148
3149 resolve_file_like_path(&package_root.join(target))
3150}
3151
3152fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
3153 match subpath {
3154 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
3155 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
3156 None => resolve_file_like_path(&package_root.join("src").join("index"))
3157 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
3158 }
3159}
3160
3161fn resolve_reexported_symbol<F, D>(
3162 file: &Path,
3163 symbol_name: &str,
3164 file_exports_symbol: &mut F,
3165 file_default_export_symbol: &mut D,
3166) -> Option<ResolvedSymbol>
3167where
3168 F: FnMut(&Path, &str) -> bool,
3169 D: FnMut(&Path) -> Option<String>,
3170{
3171 let mut visited = HashSet::new();
3172 resolve_reexported_symbol_inner(
3173 file,
3174 symbol_name,
3175 file_exports_symbol,
3176 file_default_export_symbol,
3177 &mut visited,
3178 )
3179}
3180
3181fn resolve_reexported_symbol_inner<F, D>(
3182 file: &Path,
3183 symbol_name: &str,
3184 file_exports_symbol: &mut F,
3185 file_default_export_symbol: &mut D,
3186 visited: &mut HashSet<(PathBuf, String)>,
3187) -> Option<ResolvedSymbol>
3188where
3189 F: FnMut(&Path, &str) -> bool,
3190 D: FnMut(&Path) -> Option<String>,
3191{
3192 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
3193 if !visited.insert((canon.clone(), symbol_name.to_string())) {
3194 return None;
3195 }
3196
3197 let source = std::fs::read_to_string(&canon).ok()?;
3198 let lang = detect_language(&canon)?;
3199 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3200 if symbol_name == "default" {
3201 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
3202 file: canon,
3203 symbol,
3204 });
3205 }
3206 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
3207 file: canon,
3208 symbol: symbol_name.to_string(),
3209 });
3210 }
3211
3212 let grammar = grammar_for(lang);
3213 let mut parser = Parser::new();
3214 parser.set_language(&grammar).ok()?;
3215 let tree = parser.parse(&source, None)?;
3216 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
3217
3218 let mut cursor = tree.root_node().walk();
3219 if !cursor.goto_first_child() {
3220 return None;
3221 }
3222
3223 loop {
3224 let node = cursor.node();
3225 if node.kind() == "export_statement" {
3226 if let Some(target) = resolve_reexport_statement(
3227 &source,
3228 node,
3229 from_dir,
3230 symbol_name,
3231 file_exports_symbol,
3232 file_default_export_symbol,
3233 visited,
3234 ) {
3235 return Some(target);
3236 }
3237 }
3238
3239 if !cursor.goto_next_sibling() {
3240 break;
3241 }
3242 }
3243
3244 if symbol_name == "default" {
3245 if let Some(symbol) = file_default_export_symbol(&canon) {
3246 return Some(ResolvedSymbol {
3247 file: canon,
3248 symbol,
3249 });
3250 }
3251 }
3252
3253 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
3254 return Some(ResolvedSymbol {
3255 file: canon,
3256 symbol,
3257 });
3258 }
3259
3260 if file_exports_symbol(&canon, symbol_name) {
3261 let symbol = symbol_name.to_string();
3262 return Some(ResolvedSymbol {
3263 file: canon,
3264 symbol,
3265 });
3266 }
3267
3268 None
3269}
3270
3271fn resolve_reexport_statement<F, D>(
3272 source: &str,
3273 node: tree_sitter::Node,
3274 from_dir: &Path,
3275 symbol_name: &str,
3276 file_exports_symbol: &mut F,
3277 file_default_export_symbol: &mut D,
3278 visited: &mut HashSet<(PathBuf, String)>,
3279) -> Option<ResolvedSymbol>
3280where
3281 F: FnMut(&Path, &str) -> bool,
3282 D: FnMut(&Path) -> Option<String>,
3283{
3284 let source_node = node.child_by_field_name("source")?;
3285 let module_path = string_literal_content(source, source_node)?;
3286 let target_file = resolve_module_path(from_dir, &module_path)?;
3287 let raw_export = node_text(node, source);
3288
3289 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
3290 return resolve_reexported_symbol_inner(
3291 &target_file,
3292 &source_symbol,
3293 file_exports_symbol,
3294 file_default_export_symbol,
3295 visited,
3296 )
3297 .or(Some(ResolvedSymbol {
3298 file: target_file,
3299 symbol: source_symbol,
3300 }));
3301 }
3302
3303 if raw_export.contains('*') {
3304 return resolve_reexported_symbol_inner(
3305 &target_file,
3306 symbol_name,
3307 file_exports_symbol,
3308 file_default_export_symbol,
3309 visited,
3310 );
3311 }
3312
3313 None
3314}
3315
3316fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
3317 let lang = detect_language(file)?;
3318 let grammar = grammar_for(lang);
3319 let mut parser = Parser::new();
3320 parser.set_language(&grammar).ok()?;
3321 let tree = parser.parse(source, None)?;
3322
3323 let mut cursor = tree.root_node().walk();
3324 if !cursor.goto_first_child() {
3325 return None;
3326 }
3327
3328 loop {
3329 let node = cursor.node();
3330 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
3331 let raw_export = node_text(node, source);
3332 if let Some(source_symbol) =
3333 reexport_clause_source_symbol(&raw_export, requested_export)
3334 {
3335 return Some(source_symbol);
3336 }
3337 }
3338
3339 if !cursor.goto_next_sibling() {
3340 break;
3341 }
3342 }
3343
3344 None
3345}
3346
3347fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3348 let start = raw_export.find('{')? + 1;
3349 let end = raw_export[start..].find('}')? + start;
3350 for specifier in raw_export[start..end].split(',') {
3351 let specifier = specifier.trim();
3352 if specifier.is_empty() {
3353 continue;
3354 }
3355 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3356 if let Some((imported, exported)) = specifier.split_once(" as ") {
3357 if exported.trim() == requested_export {
3358 return Some(imported.trim().to_string());
3359 }
3360 } else if specifier == requested_export {
3361 return Some(requested_export.to_string());
3362 }
3363 }
3364 None
3365}
3366
3367fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3368 let raw = source[node.byte_range()].trim();
3369 let quote = raw.chars().next()?;
3370 if quote != '\'' && quote != '"' {
3371 return None;
3372 }
3373 raw.strip_prefix(quote)
3374 .and_then(|value| value.strip_suffix(quote))
3375 .map(ToOwned::to_owned)
3376}
3377
3378fn find_index_file(dir: &Path) -> Option<PathBuf> {
3380 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
3381 for name in &candidates {
3382 let p = dir.join(name);
3383 if p.is_file() {
3384 return Some(std::fs::canonicalize(&p).unwrap_or(p));
3385 }
3386 }
3387 None
3388}
3389
3390fn resolve_aliased_import(
3393 local_name: &str,
3394 import_block: &ImportBlock,
3395 caller_dir: &Path,
3396) -> Option<(String, PathBuf)> {
3397 for imp in &import_block.imports {
3398 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3401 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3402 return Some((original, resolved_path));
3403 }
3404 }
3405 }
3406 None
3407}
3408
3409fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3413 let search = format!(" as {}", local_name);
3416 if let Some(pos) = raw_import.find(&search) {
3417 let before = &raw_import[..pos];
3419 let original = before
3421 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3422 .find(|s| !s.is_empty())?;
3423 return Some(original.to_string());
3424 }
3425 None
3426}
3427
3428pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3436 use ignore::WalkBuilder;
3437
3438 let walker = WalkBuilder::new(root)
3439 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
3444 let name = entry.file_name().to_string_lossy();
3445 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3447 return !matches!(
3448 name.as_ref(),
3449 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3450 | ".tox" | "dist" | "build"
3451 );
3452 }
3453 true
3454 })
3455 .build();
3456
3457 walker
3458 .filter_map(|entry| entry.ok())
3459 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3460 .filter(|entry| detect_language(entry.path()).is_some())
3461 .map(|entry| entry.into_path())
3462}
3463
3464#[cfg(test)]
3469mod tests {
3470 use super::*;
3471 use std::fs;
3472 use tempfile::TempDir;
3473
3474 fn setup_ts_project() -> TempDir {
3476 let dir = TempDir::new().unwrap();
3477
3478 fs::write(
3480 dir.path().join("main.ts"),
3481 r#"import { helper, compute } from './utils';
3482import * as math from './math';
3483
3484export function main() {
3485 const a = helper(1);
3486 const b = compute(a, 2);
3487 const c = math.add(a, b);
3488 return c;
3489}
3490"#,
3491 )
3492 .unwrap();
3493
3494 fs::write(
3496 dir.path().join("utils.ts"),
3497 r#"import { double } from './helpers';
3498
3499export function helper(x: number): number {
3500 return double(x);
3501}
3502
3503export function compute(a: number, b: number): number {
3504 return a + b;
3505}
3506"#,
3507 )
3508 .unwrap();
3509
3510 fs::write(
3512 dir.path().join("helpers.ts"),
3513 r#"export function double(x: number): number {
3514 return x * 2;
3515}
3516
3517export function triple(x: number): number {
3518 return x * 3;
3519}
3520"#,
3521 )
3522 .unwrap();
3523
3524 fs::write(
3526 dir.path().join("math.ts"),
3527 r#"export function add(a: number, b: number): number {
3528 return a + b;
3529}
3530
3531export function subtract(a: number, b: number): number {
3532 return a - b;
3533}
3534"#,
3535 )
3536 .unwrap();
3537
3538 dir
3539 }
3540
3541 fn setup_alias_project() -> TempDir {
3543 let dir = TempDir::new().unwrap();
3544
3545 fs::write(
3546 dir.path().join("main.ts"),
3547 r#"import { helper as h } from './utils';
3548
3549export function main() {
3550 return h(42);
3551}
3552"#,
3553 )
3554 .unwrap();
3555
3556 fs::write(
3557 dir.path().join("utils.ts"),
3558 r#"export function helper(x: number): number {
3559 return x + 1;
3560}
3561"#,
3562 )
3563 .unwrap();
3564
3565 dir
3566 }
3567
3568 fn setup_cycle_project() -> TempDir {
3570 let dir = TempDir::new().unwrap();
3571
3572 fs::write(
3573 dir.path().join("a.ts"),
3574 r#"import { funcB } from './b';
3575
3576export function funcA() {
3577 return funcB();
3578}
3579"#,
3580 )
3581 .unwrap();
3582
3583 fs::write(
3584 dir.path().join("b.ts"),
3585 r#"import { funcA } from './a';
3586
3587export function funcB() {
3588 return funcA();
3589}
3590"#,
3591 )
3592 .unwrap();
3593
3594 dir
3595 }
3596
3597 #[test]
3600 fn callgraph_single_file_call_extraction() {
3601 let dir = setup_ts_project();
3602 let mut graph = CallGraph::new(dir.path().to_path_buf());
3603
3604 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3605 let main_calls = &file_data.calls_by_symbol["main"];
3606
3607 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3608 assert!(
3609 callee_names.contains(&"helper"),
3610 "main should call helper, got: {:?}",
3611 callee_names
3612 );
3613 assert!(
3614 callee_names.contains(&"compute"),
3615 "main should call compute, got: {:?}",
3616 callee_names
3617 );
3618 assert!(
3619 callee_names.contains(&"add"),
3620 "main should call math.add (short name: add), got: {:?}",
3621 callee_names
3622 );
3623 }
3624
3625 #[test]
3626 fn callgraph_file_data_has_exports() {
3627 let dir = setup_ts_project();
3628 let mut graph = CallGraph::new(dir.path().to_path_buf());
3629
3630 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3631 assert!(
3632 file_data.exported_symbols.contains(&"helper".to_string()),
3633 "utils.ts should export helper, got: {:?}",
3634 file_data.exported_symbols
3635 );
3636 assert!(
3637 file_data.exported_symbols.contains(&"compute".to_string()),
3638 "utils.ts should export compute, got: {:?}",
3639 file_data.exported_symbols
3640 );
3641 }
3642
3643 #[test]
3646 fn callgraph_resolve_direct_import() {
3647 let dir = setup_ts_project();
3648 let mut graph = CallGraph::new(dir.path().to_path_buf());
3649
3650 let main_path = dir.path().join("main.ts");
3651 let file_data = graph.build_file(&main_path).unwrap();
3652 let import_block = file_data.import_block.clone();
3653
3654 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3655 match edge {
3656 EdgeResolution::Resolved { file, symbol } => {
3657 assert!(
3658 file.ends_with("utils.ts"),
3659 "helper should resolve to utils.ts, got: {:?}",
3660 file
3661 );
3662 assert_eq!(symbol, "helper");
3663 }
3664 EdgeResolution::Unresolved { callee_name } => {
3665 panic!("Expected resolved, got unresolved: {}", callee_name);
3666 }
3667 }
3668 }
3669
3670 #[test]
3671 fn callgraph_resolve_namespace_import() {
3672 let dir = setup_ts_project();
3673 let mut graph = CallGraph::new(dir.path().to_path_buf());
3674
3675 let main_path = dir.path().join("main.ts");
3676 let file_data = graph.build_file(&main_path).unwrap();
3677 let import_block = file_data.import_block.clone();
3678
3679 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3680 match edge {
3681 EdgeResolution::Resolved { file, symbol } => {
3682 assert!(
3683 file.ends_with("math.ts"),
3684 "math.add should resolve to math.ts, got: {:?}",
3685 file
3686 );
3687 assert_eq!(symbol, "add");
3688 }
3689 EdgeResolution::Unresolved { callee_name } => {
3690 panic!("Expected resolved, got unresolved: {}", callee_name);
3691 }
3692 }
3693 }
3694
3695 #[test]
3696 fn callgraph_resolve_aliased_import() {
3697 let dir = setup_alias_project();
3698 let mut graph = CallGraph::new(dir.path().to_path_buf());
3699
3700 let main_path = dir.path().join("main.ts");
3701 let file_data = graph.build_file(&main_path).unwrap();
3702 let import_block = file_data.import_block.clone();
3703
3704 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3705 match edge {
3706 EdgeResolution::Resolved { file, symbol } => {
3707 assert!(
3708 file.ends_with("utils.ts"),
3709 "h (alias for helper) should resolve to utils.ts, got: {:?}",
3710 file
3711 );
3712 assert_eq!(symbol, "helper");
3713 }
3714 EdgeResolution::Unresolved { callee_name } => {
3715 panic!("Expected resolved, got unresolved: {}", callee_name);
3716 }
3717 }
3718 }
3719
3720 #[test]
3721 fn callgraph_unresolved_edge_marked() {
3722 let dir = setup_ts_project();
3723 let mut graph = CallGraph::new(dir.path().to_path_buf());
3724
3725 let main_path = dir.path().join("main.ts");
3726 let file_data = graph.build_file(&main_path).unwrap();
3727 let import_block = file_data.import_block.clone();
3728
3729 let edge =
3730 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3731 assert_eq!(
3732 edge,
3733 EdgeResolution::Unresolved {
3734 callee_name: "unknownFunc".to_string()
3735 },
3736 "Unknown callee should be unresolved"
3737 );
3738 }
3739
3740 #[test]
3743 fn callgraph_cycle_detection_stops() {
3744 let dir = setup_cycle_project();
3745 let mut graph = CallGraph::new(dir.path().to_path_buf());
3746
3747 let tree = graph
3749 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
3750 .unwrap();
3751
3752 assert_eq!(tree.name, "funcA");
3753 assert!(tree.resolved);
3754
3755 fn count_depth(node: &CallTreeNode) -> usize {
3758 if node.children.is_empty() {
3759 1
3760 } else {
3761 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
3762 }
3763 }
3764
3765 let depth = count_depth(&tree);
3766 assert!(
3767 depth <= 4,
3768 "Cycle should be detected and bounded, depth was: {}",
3769 depth
3770 );
3771 }
3772
3773 #[test]
3776 fn callgraph_depth_limit_truncates() {
3777 let dir = setup_ts_project();
3778 let mut graph = CallGraph::new(dir.path().to_path_buf());
3779
3780 let tree = graph
3783 .forward_tree(&dir.path().join("main.ts"), "main", 1)
3784 .unwrap();
3785
3786 assert_eq!(tree.name, "main");
3787
3788 for child in &tree.children {
3790 assert!(
3791 child.children.is_empty(),
3792 "At depth 1, child '{}' should have no children, got {:?}",
3793 child.name,
3794 child.children.len()
3795 );
3796 }
3797 }
3798
3799 #[test]
3800 fn callgraph_depth_zero_no_children() {
3801 let dir = setup_ts_project();
3802 let mut graph = CallGraph::new(dir.path().to_path_buf());
3803
3804 let tree = graph
3805 .forward_tree(&dir.path().join("main.ts"), "main", 0)
3806 .unwrap();
3807
3808 assert_eq!(tree.name, "main");
3809 assert!(
3810 tree.children.is_empty(),
3811 "At depth 0, should have no children"
3812 );
3813 }
3814
3815 #[test]
3818 fn callgraph_forward_tree_cross_file() {
3819 let dir = setup_ts_project();
3820 let mut graph = CallGraph::new(dir.path().to_path_buf());
3821
3822 let tree = graph
3824 .forward_tree(&dir.path().join("main.ts"), "main", 5)
3825 .unwrap();
3826
3827 assert_eq!(tree.name, "main");
3828 assert!(tree.resolved);
3829
3830 let helper_child = tree.children.iter().find(|c| c.name == "helper");
3832 assert!(
3833 helper_child.is_some(),
3834 "main should have helper as child, children: {:?}",
3835 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3836 );
3837
3838 let helper = helper_child.unwrap();
3839 assert!(
3840 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
3841 "helper should be in utils.ts, got: {}",
3842 helper.file
3843 );
3844
3845 let double_child = helper.children.iter().find(|c| c.name == "double");
3847 assert!(
3848 double_child.is_some(),
3849 "helper should call double, children: {:?}",
3850 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3851 );
3852
3853 let double = double_child.unwrap();
3854 assert!(
3855 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
3856 "double should be in helpers.ts, got: {}",
3857 double.file
3858 );
3859 }
3860
3861 #[test]
3864 fn callgraph_walker_excludes_gitignored() {
3865 let dir = TempDir::new().unwrap();
3866
3867 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3869
3870 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3872 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3873 fs::write(
3874 dir.path().join("ignored_dir").join("secret.ts"),
3875 "export function secret() {}",
3876 )
3877 .unwrap();
3878
3879 fs::create_dir(dir.path().join("node_modules")).unwrap();
3881 fs::write(
3882 dir.path().join("node_modules").join("dep.ts"),
3883 "export function dep() {}",
3884 )
3885 .unwrap();
3886
3887 std::process::Command::new("git")
3889 .args(["init"])
3890 .current_dir(dir.path())
3891 .output()
3892 .unwrap();
3893
3894 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3895 let file_names: Vec<String> = files
3896 .iter()
3897 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3898 .collect();
3899
3900 assert!(
3901 file_names.contains(&"main.ts".to_string()),
3902 "Should include main.ts, got: {:?}",
3903 file_names
3904 );
3905 assert!(
3906 !file_names.contains(&"secret.ts".to_string()),
3907 "Should exclude gitignored secret.ts, got: {:?}",
3908 file_names
3909 );
3910 assert!(
3911 !file_names.contains(&"dep.ts".to_string()),
3912 "Should exclude node_modules, got: {:?}",
3913 file_names
3914 );
3915 }
3916
3917 #[test]
3918 fn callgraph_walker_only_source_files() {
3919 let dir = TempDir::new().unwrap();
3920
3921 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3922 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3923 fs::write(dir.path().join("data.json"), "{}").unwrap();
3924
3925 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3926 let file_names: Vec<String> = files
3927 .iter()
3928 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3929 .collect();
3930
3931 assert!(file_names.contains(&"main.ts".to_string()));
3932 assert!(
3933 file_names.contains(&"readme.md".to_string()),
3934 "Markdown is now a supported source language"
3935 );
3936 assert!(
3937 file_names.contains(&"data.json".to_string()),
3938 "JSON is now a supported source language"
3939 );
3940 }
3941
3942 #[test]
3945 fn callgraph_find_alias_original_simple() {
3946 let raw = "import { foo as bar } from './utils';";
3947 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3948 }
3949
3950 #[test]
3951 fn callgraph_find_alias_original_multiple() {
3952 let raw = "import { foo as bar, baz as qux } from './utils';";
3953 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3954 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3955 }
3956
3957 #[test]
3958 fn callgraph_find_alias_no_match() {
3959 let raw = "import { foo } from './utils';";
3960 assert_eq!(find_alias_original(raw, "foo"), None);
3961 }
3962
3963 #[test]
3966 fn callgraph_callers_of_direct() {
3967 let dir = setup_ts_project();
3968 let mut graph = CallGraph::new(dir.path().to_path_buf());
3969
3970 let result = graph
3972 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
3973 .unwrap();
3974
3975 assert_eq!(result.symbol, "double");
3976 assert!(result.total_callers > 0, "double should have callers");
3977 assert!(result.scanned_files > 0, "should have scanned files");
3978
3979 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
3981 assert!(
3982 utils_group.is_some(),
3983 "double should be called from utils.ts, groups: {:?}",
3984 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
3985 );
3986
3987 let group = utils_group.unwrap();
3988 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
3989 assert!(
3990 helper_caller.is_some(),
3991 "double should be called by helper, callers: {:?}",
3992 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
3993 );
3994 }
3995
3996 #[test]
3997 fn callgraph_callers_of_no_callers() {
3998 let dir = setup_ts_project();
3999 let mut graph = CallGraph::new(dir.path().to_path_buf());
4000
4001 let result = graph
4003 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
4004 .unwrap();
4005
4006 assert_eq!(result.symbol, "main");
4007 assert_eq!(result.total_callers, 0, "main should have no callers");
4008 assert!(result.callers.is_empty());
4009 }
4010
4011 #[test]
4012 fn callgraph_callers_recursive_depth() {
4013 let dir = setup_ts_project();
4014 let mut graph = CallGraph::new(dir.path().to_path_buf());
4015
4016 let result = graph
4020 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
4021 .unwrap();
4022
4023 assert!(
4024 result.total_callers >= 2,
4025 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
4026 result.total_callers
4027 );
4028
4029 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
4031 assert!(
4032 main_group.is_some(),
4033 "recursive callers should include main.ts, groups: {:?}",
4034 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
4035 );
4036 }
4037
4038 #[test]
4039 fn callgraph_invalidate_file_clears_reverse_index() {
4040 let dir = setup_ts_project();
4041 let mut graph = CallGraph::new(dir.path().to_path_buf());
4042
4043 let _ = graph
4045 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
4046 .unwrap();
4047 assert!(
4048 graph.reverse_index.is_some(),
4049 "reverse index should be built"
4050 );
4051
4052 graph.invalidate_file(&dir.path().join("utils.ts"));
4054
4055 assert!(
4057 graph.reverse_index.is_none(),
4058 "invalidate_file should clear reverse index"
4059 );
4060 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
4062 assert!(
4063 !graph.data.contains_key(&canon),
4064 "invalidate_file should remove file from data cache"
4065 );
4066 assert!(
4068 graph.project_files.is_none(),
4069 "invalidate_file should clear project_files"
4070 );
4071 }
4072
4073 #[test]
4076 fn is_entry_point_exported_function() {
4077 assert!(is_entry_point(
4078 "handleRequest",
4079 &SymbolKind::Function,
4080 true,
4081 LangId::TypeScript
4082 ));
4083 }
4084
4085 #[test]
4086 fn is_entry_point_exported_method_is_not_entry() {
4087 assert!(!is_entry_point(
4089 "handleRequest",
4090 &SymbolKind::Method,
4091 true,
4092 LangId::TypeScript
4093 ));
4094 }
4095
4096 #[test]
4097 fn is_entry_point_main_init_patterns() {
4098 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
4099 assert!(
4100 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
4101 "{} should be an entry point",
4102 name
4103 );
4104 }
4105 }
4106
4107 #[test]
4108 fn is_entry_point_test_patterns_ts() {
4109 assert!(is_entry_point(
4110 "describe",
4111 &SymbolKind::Function,
4112 false,
4113 LangId::TypeScript
4114 ));
4115 assert!(is_entry_point(
4116 "it",
4117 &SymbolKind::Function,
4118 false,
4119 LangId::TypeScript
4120 ));
4121 assert!(is_entry_point(
4122 "test",
4123 &SymbolKind::Function,
4124 false,
4125 LangId::TypeScript
4126 ));
4127 assert!(is_entry_point(
4128 "testValidation",
4129 &SymbolKind::Function,
4130 false,
4131 LangId::TypeScript
4132 ));
4133 assert!(is_entry_point(
4134 "specHelper",
4135 &SymbolKind::Function,
4136 false,
4137 LangId::TypeScript
4138 ));
4139 }
4140
4141 #[test]
4142 fn is_entry_point_test_patterns_python() {
4143 assert!(is_entry_point(
4144 "test_login",
4145 &SymbolKind::Function,
4146 false,
4147 LangId::Python
4148 ));
4149 assert!(is_entry_point(
4150 "setUp",
4151 &SymbolKind::Function,
4152 false,
4153 LangId::Python
4154 ));
4155 assert!(is_entry_point(
4156 "tearDown",
4157 &SymbolKind::Function,
4158 false,
4159 LangId::Python
4160 ));
4161 assert!(!is_entry_point(
4163 "testSomething",
4164 &SymbolKind::Function,
4165 false,
4166 LangId::Python
4167 ));
4168 }
4169
4170 #[test]
4171 fn is_entry_point_test_patterns_rust() {
4172 assert!(is_entry_point(
4173 "test_parse",
4174 &SymbolKind::Function,
4175 false,
4176 LangId::Rust
4177 ));
4178 assert!(!is_entry_point(
4179 "TestSomething",
4180 &SymbolKind::Function,
4181 false,
4182 LangId::Rust
4183 ));
4184 }
4185
4186 #[test]
4187 fn is_entry_point_test_patterns_go() {
4188 assert!(is_entry_point(
4189 "TestParsing",
4190 &SymbolKind::Function,
4191 false,
4192 LangId::Go
4193 ));
4194 assert!(!is_entry_point(
4196 "testParsing",
4197 &SymbolKind::Function,
4198 false,
4199 LangId::Go
4200 ));
4201 }
4202
4203 #[test]
4204 fn is_entry_point_non_exported_non_main_is_not_entry() {
4205 assert!(!is_entry_point(
4206 "helperUtil",
4207 &SymbolKind::Function,
4208 false,
4209 LangId::TypeScript
4210 ));
4211 }
4212
4213 #[test]
4216 fn callgraph_symbol_metadata_populated() {
4217 let dir = setup_ts_project();
4218 let mut graph = CallGraph::new(dir.path().to_path_buf());
4219
4220 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4221 assert!(
4222 file_data.symbol_metadata.contains_key("helper"),
4223 "symbol_metadata should contain helper"
4224 );
4225 let meta = &file_data.symbol_metadata["helper"];
4226 assert_eq!(meta.kind, SymbolKind::Function);
4227 assert!(meta.exported, "helper should be exported");
4228 }
4229
4230 fn setup_trace_project() -> TempDir {
4246 let dir = TempDir::new().unwrap();
4247
4248 fs::write(
4249 dir.path().join("main.ts"),
4250 r#"import { processData } from './utils';
4251
4252export function main() {
4253 const result = processData("hello");
4254 return result;
4255}
4256"#,
4257 )
4258 .unwrap();
4259
4260 fs::write(
4261 dir.path().join("service.ts"),
4262 r#"import { processData } from './utils';
4263
4264export function handleRequest(input: string): string {
4265 return processData(input);
4266}
4267"#,
4268 )
4269 .unwrap();
4270
4271 fs::write(
4272 dir.path().join("utils.ts"),
4273 r#"import { validate } from './helpers';
4274
4275export function processData(input: string): string {
4276 const valid = validate(input);
4277 if (!valid) {
4278 throw new Error("invalid input");
4279 }
4280 return input.toUpperCase();
4281}
4282"#,
4283 )
4284 .unwrap();
4285
4286 fs::write(
4287 dir.path().join("helpers.ts"),
4288 r#"export function validate(input: string): boolean {
4289 return checkFormat(input);
4290}
4291
4292function checkFormat(input: string): boolean {
4293 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
4294}
4295"#,
4296 )
4297 .unwrap();
4298
4299 fs::write(
4300 dir.path().join("test_helpers.ts"),
4301 r#"import { validate } from './helpers';
4302
4303function testValidation() {
4304 const result = validate("hello");
4305 console.log(result);
4306}
4307"#,
4308 )
4309 .unwrap();
4310
4311 std::process::Command::new("git")
4313 .args(["init"])
4314 .current_dir(dir.path())
4315 .output()
4316 .unwrap();
4317
4318 dir
4319 }
4320
4321 #[test]
4322 fn trace_to_multi_path() {
4323 let dir = setup_trace_project();
4324 let mut graph = CallGraph::new(dir.path().to_path_buf());
4325
4326 let result = graph
4327 .trace_to(
4328 &dir.path().join("helpers.ts"),
4329 "checkFormat",
4330 10,
4331 usize::MAX,
4332 )
4333 .unwrap();
4334
4335 assert_eq!(result.target_symbol, "checkFormat");
4336 assert!(
4337 result.total_paths >= 2,
4338 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
4339 result.total_paths,
4340 result
4341 .paths
4342 .iter()
4343 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
4344 .collect::<Vec<_>>()
4345 );
4346
4347 for path in &result.paths {
4349 assert!(
4350 path.hops.first().unwrap().is_entry_point,
4351 "First hop should be an entry point, got: {}",
4352 path.hops.first().unwrap().symbol
4353 );
4354 assert_eq!(
4355 path.hops.last().unwrap().symbol,
4356 "checkFormat",
4357 "Last hop should be checkFormat"
4358 );
4359 }
4360
4361 assert!(
4363 result.entry_points_found >= 2,
4364 "should find at least 2 entry points, got {}",
4365 result.entry_points_found
4366 );
4367 }
4368
4369 #[test]
4370 fn trace_to_single_path() {
4371 let dir = setup_trace_project();
4372 let mut graph = CallGraph::new(dir.path().to_path_buf());
4373
4374 let result = graph
4378 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
4379 .unwrap();
4380
4381 assert_eq!(result.target_symbol, "validate");
4382 assert!(
4383 result.total_paths >= 2,
4384 "validate should have at least 2 paths, got {}",
4385 result.total_paths
4386 );
4387 }
4388
4389 #[test]
4390 fn trace_to_cycle_detection() {
4391 let dir = setup_cycle_project();
4392 let mut graph = CallGraph::new(dir.path().to_path_buf());
4393
4394 let result = graph
4396 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
4397 .unwrap();
4398
4399 assert_eq!(result.target_symbol, "funcA");
4401 }
4402
4403 #[test]
4404 fn trace_to_depth_limit() {
4405 let dir = setup_trace_project();
4406 let mut graph = CallGraph::new(dir.path().to_path_buf());
4407
4408 let result = graph
4410 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
4411 .unwrap();
4412
4413 assert_eq!(result.target_symbol, "checkFormat");
4417
4418 let deep_result = graph
4420 .trace_to(
4421 &dir.path().join("helpers.ts"),
4422 "checkFormat",
4423 10,
4424 usize::MAX,
4425 )
4426 .unwrap();
4427
4428 assert!(
4429 result.total_paths <= deep_result.total_paths,
4430 "shallow trace should find <= paths compared to deep: {} vs {}",
4431 result.total_paths,
4432 deep_result.total_paths
4433 );
4434 }
4435
4436 #[test]
4437 fn trace_to_entry_point_target() {
4438 let dir = setup_trace_project();
4439 let mut graph = CallGraph::new(dir.path().to_path_buf());
4440
4441 let result = graph
4443 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
4444 .unwrap();
4445
4446 assert_eq!(result.target_symbol, "main");
4447 assert!(
4448 result.total_paths >= 1,
4449 "main should have at least 1 path (itself), got {}",
4450 result.total_paths
4451 );
4452 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
4454 assert!(
4455 trivial.is_some(),
4456 "should have a trivial path with just the entry point itself"
4457 );
4458 }
4459
4460 #[test]
4463 fn extract_parameters_typescript() {
4464 let params = extract_parameters(
4465 "function processData(input: string, count: number): void",
4466 LangId::TypeScript,
4467 );
4468 assert_eq!(params, vec!["input", "count"]);
4469 }
4470
4471 #[test]
4472 fn extract_parameters_typescript_optional() {
4473 let params = extract_parameters(
4474 "function fetch(url: string, options?: RequestInit): Promise<Response>",
4475 LangId::TypeScript,
4476 );
4477 assert_eq!(params, vec!["url", "options"]);
4478 }
4479
4480 #[test]
4481 fn extract_parameters_typescript_defaults() {
4482 let params = extract_parameters(
4483 "function greet(name: string, greeting: string = \"hello\"): string",
4484 LangId::TypeScript,
4485 );
4486 assert_eq!(params, vec!["name", "greeting"]);
4487 }
4488
4489 #[test]
4490 fn extract_parameters_typescript_rest() {
4491 let params = extract_parameters(
4492 "function sum(...numbers: number[]): number",
4493 LangId::TypeScript,
4494 );
4495 assert_eq!(params, vec!["numbers"]);
4496 }
4497
4498 #[test]
4499 fn extract_parameters_python_self_skipped() {
4500 let params = extract_parameters(
4501 "def process(self, data: str, count: int) -> bool",
4502 LangId::Python,
4503 );
4504 assert_eq!(params, vec!["data", "count"]);
4505 }
4506
4507 #[test]
4508 fn extract_parameters_python_no_self() {
4509 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4510 assert_eq!(params, vec!["input"]);
4511 }
4512
4513 #[test]
4514 fn extract_parameters_python_star_args() {
4515 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4516 assert_eq!(params, vec!["args", "kwargs"]);
4517 }
4518
4519 #[test]
4520 fn extract_parameters_rust_self_skipped() {
4521 let params = extract_parameters(
4522 "fn process(&self, data: &str, count: usize) -> bool",
4523 LangId::Rust,
4524 );
4525 assert_eq!(params, vec!["data", "count"]);
4526 }
4527
4528 #[test]
4529 fn extract_parameters_rust_mut_self_skipped() {
4530 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4531 assert_eq!(params, vec!["value"]);
4532 }
4533
4534 #[test]
4535 fn extract_parameters_rust_no_self() {
4536 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4537 assert_eq!(params, vec!["input"]);
4538 }
4539
4540 #[test]
4541 fn extract_parameters_rust_mut_param() {
4542 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4543 assert_eq!(params, vec!["buf", "len"]);
4544 }
4545
4546 #[test]
4547 fn extract_parameters_go() {
4548 let params = extract_parameters(
4549 "func ProcessData(input string, count int) error",
4550 LangId::Go,
4551 );
4552 assert_eq!(params, vec!["input", "count"]);
4553 }
4554
4555 #[test]
4556 fn extract_parameters_empty() {
4557 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4558 assert!(
4559 params.is_empty(),
4560 "no-arg function should return empty params"
4561 );
4562 }
4563
4564 #[test]
4565 fn extract_parameters_no_parens() {
4566 let params = extract_parameters("const x = 42", LangId::TypeScript);
4567 assert!(params.is_empty(), "no parens should return empty params");
4568 }
4569
4570 #[test]
4571 fn extract_parameters_javascript() {
4572 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4573 assert_eq!(params, vec!["event", "target"]);
4574 }
4575}