1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5use tree_sitter::{Node, Parser};
6
7use crate::callgraph::{self, TraceToSymbolCandidate};
8use crate::callgraph_store::{
9 CallGraphStore, CallGraphStoreError, StoreCallSite, StoreNode, StoreUnresolvedCall,
10};
11use crate::edit::line_col_to_byte;
12use crate::error::AftError;
13use crate::inspect::job::is_test_file;
14use crate::parser::{
15 detect_language, extract_symbols_from_tree, grammar_for, FileParser, SharedSymbolCache,
16};
17use crate::protocol::Response;
18use crate::symbols::Symbol;
19
20pub type StoreAdapterResult<T> = Result<T, CallGraphStoreError>;
21
22const TRACE_DATA_RESOLVER_PROVENANCE: &str = "treesitter+resolver";
23const HUB_SUMMARY_THRESHOLD: usize = 20;
24const HUB_SUMMARY_LIMIT: usize = 15;
25
26#[derive(Debug, Clone, Serialize)]
27pub struct StoreHubSummary {
28 pub message: String,
29 pub total: usize,
30 pub hidden_tests: usize,
31 pub shown: usize,
32 pub threshold: usize,
33 pub limit: usize,
34}
35
36#[derive(Debug, Clone, Default)]
37struct EdgeMarker {
38 approximate: Option<bool>,
39 resolved_by: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct StoreCallersResult {
44 pub symbol: String,
45 pub file: String,
46 pub callers: Vec<StoreCallerGroup>,
47 pub total_callers: usize,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub hub_summary: Option<StoreHubSummary>,
50 pub scanned_files: usize,
51 pub depth_limited: bool,
52 pub truncated: usize,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct StoreCallerGroup {
57 pub file: String,
58 pub callers: Vec<StoreCallerEntry>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct StoreCallerEntry {
63 pub symbol: String,
64 pub line: u32,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub approximate: Option<bool>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub resolved_by: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct StoreCallTreeNode {
73 pub name: String,
74 pub file: String,
75 pub line: u32,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub signature: Option<String>,
78 pub resolved: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub approximate: Option<bool>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub resolved_by: Option<String>,
83 pub children: Vec<StoreCallTreeNode>,
84 pub depth_limited: bool,
85 pub truncated: usize,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct StoreImpactResult {
90 pub symbol: String,
91 pub file: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub signature: Option<String>,
94 pub parameters: Vec<String>,
95 pub total_affected: usize,
96 pub affected_files: usize,
97 pub callers: Vec<StoreImpactCaller>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub hub_summary: Option<StoreHubSummary>,
100 pub depth_limited: bool,
101 pub truncated: usize,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub struct StoreImpactCaller {
106 pub caller_symbol: String,
107 pub caller_file: String,
108 pub line: u32,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub signature: Option<String>,
111 pub is_entry_point: bool,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub call_expression: Option<String>,
114 pub parameters: Vec<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub approximate: Option<bool>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub resolved_by: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize)]
122pub struct StoreTraceHop {
123 pub symbol: String,
124 pub file: String,
125 pub line: u32,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub signature: Option<String>,
128 pub is_entry_point: bool,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub approximate: Option<bool>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub resolved_by: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct StoreTracePath {
137 pub hops: Vec<StoreTraceHop>,
138}
139
140#[derive(Debug, Clone, Serialize)]
141pub struct StoreTraceToResult {
142 pub target_symbol: String,
143 pub target_file: String,
144 pub paths: Vec<StoreTracePath>,
145 pub total_paths: usize,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub hub_summary: Option<StoreHubSummary>,
148 pub entry_points_found: usize,
149 pub max_depth_reached: bool,
150 pub truncated_paths: usize,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct StoreTraceToSymbolHop {
155 pub symbol: String,
156 pub file: String,
157 pub line: u32,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub approximate: Option<bool>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub resolved_by: Option<String>,
162}
163
164#[derive(Debug, Clone, Serialize)]
165pub struct StoreTraceToSymbolResult {
166 pub path: Option<Vec<StoreTraceToSymbolHop>>,
167 pub complete: bool,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub reason: Option<String>,
170}
171
172enum ForwardCall {
173 Resolved(StoreCallSite),
174 Unresolved(StoreUnresolvedCall),
175}
176
177#[derive(Clone)]
178enum TraceForwardCall {
179 Resolved(StoreCallSite),
180 Unresolved(StoreUnresolvedCall),
181}
182
183impl TraceForwardCall {
184 fn byte_start(&self) -> usize {
185 match self {
186 Self::Resolved(site) => site.byte_start,
187 Self::Unresolved(call) => call.byte_start,
188 }
189 }
190
191 fn byte_end(&self) -> usize {
192 match self {
193 Self::Resolved(site) => site.byte_end,
194 Self::Unresolved(call) => call.byte_end,
195 }
196 }
197
198 fn line(&self) -> u32 {
199 match self {
200 Self::Resolved(site) => site.line,
201 Self::Unresolved(call) => call.line,
202 }
203 }
204
205 fn matches_position(&self, byte_start: usize, byte_end: usize) -> bool {
206 self.byte_start() == byte_start && self.byte_end() == byte_end
207 }
208}
209
210impl ForwardCall {
211 fn byte_start(&self) -> usize {
212 match self {
213 Self::Resolved(site) => site.byte_start,
214 Self::Unresolved(call) => call.byte_start,
215 }
216 }
217
218 fn line(&self) -> u32 {
219 match self {
220 Self::Resolved(site) => site.line,
221 Self::Unresolved(call) => call.line,
222 }
223 }
224
225 fn call_site_key(&self) -> (String, u32, String) {
226 match self {
227 Self::Resolved(site) => (
228 site.caller.file.clone(),
229 site.line,
230 format!("{}::{}", site.target_file, site.target_symbol),
231 ),
232 Self::Unresolved(call) => (call.caller.file.clone(), call.line, call.symbol.clone()),
233 }
234 }
235}
236
237#[derive(Clone)]
238struct ResolvedStoreSymbol {
239 representative: StoreNode,
240 nodes: Vec<StoreNode>,
241}
242
243#[derive(Clone)]
244struct TraceElem {
245 node: StoreNode,
246 edge: EdgeMarker,
247}
248
249fn edge_marker(site: &StoreCallSite) -> EdgeMarker {
250 if let Some(resolved_by) = site.supplemental_resolution() {
251 EdgeMarker {
252 approximate: Some(site.approximate()),
253 resolved_by: Some(resolved_by.to_string()),
254 }
255 } else {
256 EdgeMarker::default()
257 }
258}
259
260fn edge_approximate(site: &StoreCallSite) -> Option<bool> {
261 site.supplemental_resolution().map(|_| site.approximate())
262}
263
264fn edge_resolved_by(site: &StoreCallSite) -> Option<String> {
265 site.supplemental_resolution().map(ToString::to_string)
266}
267
268fn test_hidden_summary(
269 kind: &str,
270 total: usize,
271 hidden_tests: usize,
272 shown: usize,
273) -> StoreHubSummary {
274 StoreHubSummary {
275 message: format!(
276 "Next: {total} {kind} ({hidden_tests} in tests, hidden — pass includeTests) — narrow with scope"
277 ),
278 total,
279 hidden_tests,
280 shown,
281 threshold: HUB_SUMMARY_THRESHOLD,
282 limit: HUB_SUMMARY_LIMIT,
283 }
284}
285
286fn included_summary(
287 kind: &str,
288 total: usize,
289 hidden_tests: usize,
290 shown: usize,
291) -> StoreHubSummary {
292 let test_note = if hidden_tests == 0 {
293 String::new()
294 } else {
295 format!(" ({hidden_tests} in tests, included)")
296 };
297 StoreHubSummary {
298 message: format!("Next: {total} {kind}{test_note} — showing {shown}; narrow with scope"),
299 total,
300 hidden_tests,
301 shown,
302 threshold: HUB_SUMMARY_THRESHOLD,
303 limit: HUB_SUMMARY_LIMIT,
304 }
305}
306
307fn callsite_is_from_test(site: &StoreCallSite) -> bool {
308 is_test_file(&site.caller.file)
309}
310
311fn trace_path_starts_in_test(path: &StoreTracePath) -> bool {
312 path.hops.first().is_some_and(|hop| is_test_file(&hop.file))
313}
314
315fn dedup_sites_for_summary(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
316 let mut seen = BTreeSet::new();
317 sites
318 .into_iter()
319 .filter(|site| seen.insert((site.caller.symbol.clone(), site.target_symbol.clone())))
320 .collect()
321}
322
323fn trace_path_shape(path: &StoreTracePath) -> Vec<(String, String)> {
324 path.hops
325 .iter()
326 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
327 .collect()
328}
329
330fn dedup_paths_for_summary(paths: Vec<StoreTracePath>) -> Vec<StoreTracePath> {
331 let mut seen = BTreeSet::new();
332 paths
333 .into_iter()
334 .filter(|path| seen.insert(trace_path_shape(path)))
335 .collect()
336}
337
338fn filter_call_tree_tests(node: &mut StoreCallTreeNode) {
339 node.children.retain(|child| !is_test_file(&child.file));
340 for child in &mut node.children {
341 filter_call_tree_tests(child);
342 }
343}
344
345pub fn callers_result(
346 store: &CallGraphStore,
347 file: &Path,
348 symbol: &str,
349 depth: usize,
350 include_tests: bool,
351) -> StoreAdapterResult<StoreCallersResult> {
352 let target = resolve_symbol_query(store, file, symbol)?;
353 let effective_depth = depth.max(1);
354 let mut visited = HashSet::new();
355 let mut sites = Vec::new();
356 let mut depth_limited = false;
357 let mut truncated = 0usize;
358
359 collect_callers_recursive(
360 store,
361 &target.representative.file,
362 &target.representative.symbol,
363 effective_depth,
364 0,
365 &mut visited,
366 &mut sites,
367 &mut depth_limited,
368 &mut truncated,
369 )?;
370
371 let mut sites = dedup_call_sites(sites);
372 sites.sort_by(|left, right| {
373 left.caller
374 .file
375 .cmp(&right.caller.file)
376 .then(left.line.cmp(&right.line))
377 .then(left.caller.symbol.cmp(&right.caller.symbol))
378 });
379 let total_callers = sites.len();
380 let hidden_tests = sites
381 .iter()
382 .filter(|site| callsite_is_from_test(site))
383 .count();
384 let summarize = total_callers > HUB_SUMMARY_THRESHOLD;
385 let visible_sites = sites
386 .into_iter()
387 .filter(|site| include_tests || !callsite_is_from_test(site))
388 .collect::<Vec<_>>();
389 let visible_sites = if summarize {
390 dedup_sites_for_summary(visible_sites)
391 .into_iter()
392 .take(HUB_SUMMARY_LIMIT)
393 .collect::<Vec<_>>()
394 } else {
395 visible_sites
396 };
397 let hub_summary = if summarize {
398 Some(if include_tests {
399 included_summary("callers", total_callers, hidden_tests, visible_sites.len())
400 } else {
401 test_hidden_summary("callers", total_callers, hidden_tests, visible_sites.len())
402 })
403 } else {
404 None
405 };
406 let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
407 for site in visible_sites {
408 groups
409 .entry(site.caller.file.clone())
410 .or_default()
411 .push(StoreCallerEntry {
412 symbol: site.caller.symbol.clone(),
413 line: site.line,
414 approximate: edge_approximate(&site),
415 resolved_by: edge_resolved_by(&site),
416 });
417 }
418
419 Ok(StoreCallersResult {
420 symbol: target.representative.symbol,
421 file: target.representative.file,
422 callers: groups
423 .into_iter()
424 .map(|(file, callers)| StoreCallerGroup { file, callers })
425 .collect(),
426 total_callers,
427 hub_summary,
428 scanned_files: store.indexed_file_count()?,
429 depth_limited,
430 truncated,
431 })
432}
433
434pub fn call_tree_result(
435 store: &CallGraphStore,
436 file: &Path,
437 symbol: &str,
438 depth: usize,
439 include_tests: bool,
440) -> StoreAdapterResult<StoreCallTreeNode> {
441 let target = resolve_symbol_query(store, file, symbol)?;
442 let mut visited = HashSet::new();
443 let mut tree = call_tree_inner(store, &target, depth, 0, &mut visited)?;
444 if !include_tests {
445 filter_call_tree_tests(&mut tree);
446 }
447 Ok(tree)
448}
449
450pub fn impact_result(
451 store: &CallGraphStore,
452 file: &Path,
453 symbol: &str,
454 depth: usize,
455 include_tests: bool,
456) -> StoreAdapterResult<StoreImpactResult> {
457 let target = resolve_symbol_query(store, file, symbol)?;
458 let effective_depth = depth.max(1);
459 let mut visited = HashSet::new();
460 let mut sites = Vec::new();
461 let mut depth_limited = false;
462 let mut truncated = 0usize;
463
464 collect_callers_recursive(
465 store,
466 &target.representative.file,
467 &target.representative.symbol,
468 effective_depth,
469 0,
470 &mut visited,
471 &mut sites,
472 &mut depth_limited,
473 &mut truncated,
474 )?;
475
476 let mut sites = dedup_call_sites(sites);
477 sites.sort_by(|left, right| {
478 left.caller
479 .file
480 .cmp(&right.caller.file)
481 .then(left.line.cmp(&right.line))
482 .then(left.caller.symbol.cmp(&right.caller.symbol))
483 });
484 let total_affected = sites.len();
485 let hidden_tests = sites
486 .iter()
487 .filter(|site| callsite_is_from_test(site))
488 .count();
489 let summarize = total_affected > HUB_SUMMARY_THRESHOLD;
490 let affected_files = sites
491 .iter()
492 .map(|site| site.caller.file.clone())
493 .collect::<BTreeSet<_>>()
494 .len();
495 let visible_sites = sites
496 .into_iter()
497 .filter(|site| include_tests || !callsite_is_from_test(site))
498 .collect::<Vec<_>>();
499 let visible_sites = if summarize {
500 dedup_sites_for_summary(visible_sites)
501 .into_iter()
502 .take(HUB_SUMMARY_LIMIT)
503 .collect::<Vec<_>>()
504 } else {
505 visible_sites
506 };
507 let hub_summary = if summarize {
508 Some(if include_tests {
509 included_summary(
510 "affected callers",
511 total_affected,
512 hidden_tests,
513 visible_sites.len(),
514 )
515 } else {
516 test_hidden_summary(
517 "affected callers",
518 total_affected,
519 hidden_tests,
520 visible_sites.len(),
521 )
522 })
523 } else {
524 None
525 };
526 let target_signature = target.representative.signature.clone();
527 let target_parameters = target_signature
528 .as_deref()
529 .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
530 .unwrap_or_default();
531
532 let mut callers = Vec::new();
533 for site in visible_sites {
534 callers.push(StoreImpactCaller {
535 caller_symbol: site.caller.symbol.clone(),
536 caller_file: site.caller.file.clone(),
537 line: site.line,
538 signature: site.caller.signature.clone(),
539 is_entry_point: site.caller.is_entry_point,
540 call_expression: read_source_line(
541 &store.project_root().join(&site.caller.file),
542 site.line,
543 ),
544 parameters: site
545 .caller
546 .signature
547 .as_deref()
548 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
549 .unwrap_or_default(),
550 approximate: edge_approximate(&site),
551 resolved_by: edge_resolved_by(&site),
552 });
553 }
554 callers.sort_by(|left, right| {
555 left.caller_file
556 .cmp(&right.caller_file)
557 .then(left.line.cmp(&right.line))
558 });
559
560 Ok(StoreImpactResult {
561 symbol: target.representative.symbol,
562 file: target.representative.file,
563 signature: target_signature,
564 parameters: target_parameters,
565 total_affected,
566 affected_files,
567 callers,
568 hub_summary,
569 depth_limited,
570 truncated,
571 })
572}
573
574pub fn trace_to_result(
575 store: &CallGraphStore,
576 file: &Path,
577 symbol: &str,
578 max_depth: usize,
579 include_tests: bool,
580) -> StoreAdapterResult<StoreTraceToResult> {
581 let target = resolve_symbol_query(store, file, symbol)?;
582 let effective_max = if max_depth == 0 { 10 } else { max_depth };
583
584 let initial = vec![TraceElem {
585 node: target.representative.clone(),
586 edge: EdgeMarker::default(),
587 }];
588 let mut complete_paths = Vec::new();
589 if target.representative.is_entry_point {
590 complete_paths.push(initial.clone());
591 }
592
593 let mut queue = vec![(initial, 0usize)];
594 let mut max_depth_reached = false;
595 let mut truncated_paths = 0usize;
596
597 while let Some((path, depth)) = queue.pop() {
598 if depth >= effective_max {
599 max_depth_reached = true;
600 continue;
601 }
602 let Some(current) = path.last() else {
603 continue;
604 };
605 let callers = dedup_call_sites(
606 store.direct_callers_of(Path::new(¤t.node.file), ¤t.node.symbol)?,
607 );
608 if callers.is_empty() {
609 if path.len() > 1 {
610 truncated_paths += 1;
611 }
612 continue;
613 }
614
615 let mut has_new_path = false;
616 for site in callers {
617 if path.iter().any(|elem| {
618 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
619 }) {
620 continue;
621 }
622 has_new_path = true;
623 let mut next_path = path.clone();
624 if let Some(current) = next_path.last_mut() {
625 current.edge = edge_marker(&site);
626 }
627 next_path.push(TraceElem {
628 node: site.caller.clone(),
629 edge: EdgeMarker::default(),
630 });
631 if site.caller.is_entry_point {
632 complete_paths.push(next_path.clone());
633 }
634 queue.push((next_path, depth + 1));
635 }
636 if !has_new_path && path.len() > 1 {
637 truncated_paths += 1;
638 }
639 }
640
641 let mut paths: Vec<StoreTracePath> = complete_paths
642 .into_iter()
643 .map(|mut elems| {
644 elems.reverse();
645 let hops = elems
646 .iter()
647 .enumerate()
648 .map(|(index, elem)| StoreTraceHop {
649 symbol: elem.node.symbol.clone(),
650 file: elem.node.file.clone(),
651 line: elem.node.line,
652 signature: elem.node.signature.clone(),
653 is_entry_point: index == 0 && elem.node.is_entry_point,
654 approximate: elem.edge.approximate,
655 resolved_by: elem.edge.resolved_by.clone(),
656 })
657 .collect();
658 StoreTracePath { hops }
659 })
660 .collect();
661 paths.sort_by(|left, right| {
662 let left_entry = left
663 .hops
664 .first()
665 .map(|hop| hop.symbol.as_str())
666 .unwrap_or("");
667 let right_entry = right
668 .hops
669 .first()
670 .map(|hop| hop.symbol.as_str())
671 .unwrap_or("");
672 left_entry
673 .cmp(right_entry)
674 .then(left.hops.len().cmp(&right.hops.len()))
675 });
676 let total_paths = paths.len();
677 let hidden_tests = paths
678 .iter()
679 .filter(|path| trace_path_starts_in_test(path))
680 .count();
681 let summarize = total_paths > HUB_SUMMARY_THRESHOLD;
682 let visible_paths = paths
683 .into_iter()
684 .filter(|path| include_tests || !trace_path_starts_in_test(path))
685 .collect::<Vec<_>>();
686 let paths = if summarize {
687 dedup_paths_for_summary(visible_paths)
688 .into_iter()
689 .take(HUB_SUMMARY_LIMIT)
690 .collect::<Vec<_>>()
691 } else {
692 visible_paths
693 };
694 let hub_summary = if summarize {
695 Some(if include_tests {
696 included_summary("paths", total_paths, hidden_tests, paths.len())
697 } else {
698 test_hidden_summary("paths", total_paths, hidden_tests, paths.len())
699 })
700 } else {
701 None
702 };
703
704 let entry_points_found = paths
705 .iter()
706 .filter_map(|path| path.hops.first())
707 .filter(|hop| hop.is_entry_point)
708 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
709 .collect::<HashSet<_>>()
710 .len();
711
712 Ok(StoreTraceToResult {
713 target_symbol: target.representative.symbol,
714 target_file: target.representative.file,
715 total_paths,
716 hub_summary,
717 paths,
718 entry_points_found,
719 max_depth_reached,
720 truncated_paths,
721 })
722}
723
724pub fn ensure_symbol_resolves(
725 store: &CallGraphStore,
726 file: &Path,
727 symbol: &str,
728) -> StoreAdapterResult<()> {
729 resolve_symbol_query(store, file, symbol).map(|_| ())
730}
731
732pub fn trace_to_symbol_candidates(
733 store: &CallGraphStore,
734 to_symbol: &str,
735) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
736 store.trace_to_symbol_candidates(to_symbol)
737}
738
739pub fn trace_to_symbol_result(
740 store: &CallGraphStore,
741 file: &Path,
742 symbol: &str,
743 to_symbol: &str,
744 to_file: Option<&Path>,
745 max_depth: usize,
746 include_tests: bool,
747) -> StoreAdapterResult<StoreTraceToSymbolResult> {
748 let origin = resolve_symbol_query(store, file, symbol)?;
749 let target_file = to_file.map(|path| relative_file(store, path));
750 let effective_max = if max_depth == 0 {
751 10
752 } else {
753 max_depth.min(16)
754 };
755
756 let start_hop = trace_to_symbol_hop(&origin.representative);
757 if trace_to_symbol_matches_target(
758 &origin.representative.file,
759 &origin.representative.symbol,
760 to_symbol,
761 target_file.as_deref(),
762 ) {
763 return Ok(StoreTraceToSymbolResult {
764 path: Some(vec![start_hop]),
765 complete: true,
766 reason: None,
767 });
768 }
769
770 let mut queue = VecDeque::new();
771 queue.push_back((
772 origin.representative.file.clone(),
773 origin.representative.symbol.clone(),
774 vec![start_hop],
775 0usize,
776 ));
777 let mut visited = HashSet::new();
778 visited.insert((
779 origin.representative.file.clone(),
780 origin.representative.symbol.clone(),
781 ));
782 let mut max_depth_exhausted = false;
783
784 while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
785 let callees = forward_resolved_callees(store, ¤t_file, ¤t_symbol)?;
786
787 if depth >= effective_max {
788 if callees
789 .iter()
790 .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
791 {
792 max_depth_exhausted = true;
793 }
794 continue;
795 }
796
797 for (callee, edge) in callees {
798 if !include_tests && is_test_file(&callee.file) {
799 continue;
800 }
801 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
802 continue;
803 }
804 let mut next_path = path.clone();
805 next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
806 if trace_to_symbol_matches_target(
807 &callee.file,
808 &callee.symbol,
809 to_symbol,
810 target_file.as_deref(),
811 ) {
812 return Ok(StoreTraceToSymbolResult {
813 path: Some(next_path),
814 complete: true,
815 reason: None,
816 });
817 }
818 queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
819 }
820 }
821
822 if max_depth_exhausted {
823 Ok(StoreTraceToSymbolResult {
824 path: None,
825 complete: false,
826 reason: Some("max_depth_exhausted".to_string()),
827 })
828 } else {
829 Ok(StoreTraceToSymbolResult {
830 path: None,
831 complete: true,
832 reason: Some("no_path_found".to_string()),
833 })
834 }
835}
836
837pub fn trace_data_result(
838 store: &CallGraphStore,
839 file: &Path,
840 symbol: &str,
841 expression: &str,
842 max_depth: usize,
843 symbol_cache: SharedSymbolCache,
844) -> StoreAdapterResult<callgraph::TraceDataResult> {
845 let origin_path = absolute_file(store, file);
846 let origin_file = relative_file(store, &origin_path);
847 let origin_symbol = resolve_symbol_query_with_cache(&origin_path, symbol, &symbol_cache)?;
848
849 let mut hops = Vec::new();
850 let mut depth_limited = false;
851 let mut visited = HashSet::new();
852 trace_data_inner(
853 store,
854 &symbol_cache,
855 &origin_path,
856 &origin_symbol,
857 expression,
858 max_depth,
859 0,
860 &mut hops,
861 &mut depth_limited,
862 &mut visited,
863 )?;
864
865 Ok(callgraph::TraceDataResult {
866 expression: expression.to_string(),
867 origin_file,
868 origin_symbol,
869 hops,
870 depth_limited,
871 })
872}
873
874#[allow(clippy::too_many_arguments)]
875fn trace_data_inner(
876 store: &CallGraphStore,
877 symbol_cache: &SharedSymbolCache,
878 file: &Path,
879 symbol: &str,
880 tracking_name: &str,
881 max_depth: usize,
882 current_depth: usize,
883 hops: &mut Vec<callgraph::DataFlowHop>,
884 depth_limited: &mut bool,
885 visited: &mut HashSet<(String, String, String)>,
886) -> StoreAdapterResult<()> {
887 let rel_file = relative_file(store, file);
888 let visit_key = (
889 rel_file.clone(),
890 symbol.to_string(),
891 tracking_name.to_string(),
892 );
893 if visited.contains(&visit_key) {
894 return Ok(());
895 }
896 visited.insert(visit_key);
897
898 let current = resolve_exact_symbol(store, &rel_file, symbol, None)?
899 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
900 let current_calls = trace_forward_calls_for_nodes(store, ¤t.nodes)?;
901
902 let source = std::fs::read_to_string(file)?;
905 let Some(lang) = detect_language(file) else {
906 return Ok(());
907 };
908 let grammar = grammar_for(lang);
909 let mut parser = Parser::new();
910 parser
911 .set_language(&grammar)
912 .map_err(|error| AftError::ParseError {
913 message: format!("grammar init failed for {:?}: {}", lang, error),
914 })?;
915 let tree = parser
916 .parse(&source, None)
917 .ok_or_else(|| AftError::ParseError {
918 message: format!("parse failed for {}", file.display()),
919 })?;
920 let symbols = extract_symbols_from_tree(&source, &tree, lang)?;
921 let sym_info = symbols
922 .iter()
923 .find(|candidate| {
924 symbol_identity_from_cache(candidate) == symbol || candidate.name == symbol
925 })
926 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
927
928 let body_start = line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
929 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
930 let Some(body_node) = find_node_covering_range(tree.root_node(), body_start, body_end) else {
931 return Ok(());
932 };
933
934 let mut tracked_names = vec![tracking_name.to_string()];
935 walk_for_data_flow(
936 store,
937 symbol_cache,
938 body_node,
939 &source,
940 ¤t_calls,
941 &mut tracked_names,
942 symbol,
943 &rel_file,
944 max_depth,
945 current_depth,
946 hops,
947 depth_limited,
948 visited,
949 )
950}
951
952#[allow(clippy::too_many_arguments)]
953fn walk_for_data_flow(
954 store: &CallGraphStore,
955 symbol_cache: &SharedSymbolCache,
956 node: Node<'_>,
957 source: &str,
958 current_calls: &[TraceForwardCall],
959 tracked_names: &mut Vec<String>,
960 symbol: &str,
961 rel_file: &str,
962 max_depth: usize,
963 current_depth: usize,
964 hops: &mut Vec<callgraph::DataFlowHop>,
965 depth_limited: &mut bool,
966 visited: &mut HashSet<(String, String, String)>,
967) -> StoreAdapterResult<()> {
968 let kind = node.kind();
969 let is_var_decl = matches!(
970 kind,
971 "variable_declarator"
972 | "assignment_expression"
973 | "augmented_assignment_expression"
974 | "assignment"
975 | "let_declaration"
976 | "short_var_declaration"
977 );
978
979 if is_var_decl {
980 if let Some((new_name, init_text, line, is_approx)) =
981 extract_assignment_info(node, source, tracked_names)
982 {
983 if !is_approx {
984 hops.push(callgraph::DataFlowHop {
985 file: rel_file.to_string(),
986 symbol: symbol.to_string(),
987 variable: new_name.clone(),
988 line,
989 flow_type: "assignment".to_string(),
990 approximate: false,
991 });
992 tracked_names.push(new_name);
993 } else {
994 hops.push(callgraph::DataFlowHop {
995 file: rel_file.to_string(),
996 symbol: symbol.to_string(),
997 variable: init_text,
998 line,
999 flow_type: "assignment".to_string(),
1000 approximate: true,
1001 });
1002 return Ok(());
1003 }
1004 }
1005 }
1006
1007 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1008 check_call_for_data_flow(
1009 store,
1010 symbol_cache,
1011 node,
1012 source,
1013 current_calls,
1014 tracked_names,
1015 symbol,
1016 rel_file,
1017 max_depth,
1018 current_depth,
1019 hops,
1020 depth_limited,
1021 visited,
1022 )?;
1023 }
1024
1025 let mut cursor = node.walk();
1026 if cursor.goto_first_child() {
1027 loop {
1028 walk_for_data_flow(
1029 store,
1030 symbol_cache,
1031 cursor.node(),
1032 source,
1033 current_calls,
1034 tracked_names,
1035 symbol,
1036 rel_file,
1037 max_depth,
1038 current_depth,
1039 hops,
1040 depth_limited,
1041 visited,
1042 )?;
1043 if !cursor.goto_next_sibling() {
1044 break;
1045 }
1046 }
1047 }
1048 Ok(())
1049}
1050
1051fn extract_assignment_info(
1052 node: Node<'_>,
1053 source: &str,
1054 tracked_names: &[String],
1055) -> Option<(String, String, u32, bool)> {
1056 let kind = node.kind();
1057 let line = node.start_position().row as u32 + 1;
1058
1059 match kind {
1060 "variable_declarator" => {
1061 let name_node = node.child_by_field_name("name")?;
1062 let value_node = node.child_by_field_name("value")?;
1063 let name_text = trace_node_text(name_node, source);
1064 let value_text = trace_node_text(value_node, source);
1065
1066 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1067 if tracked_names
1068 .iter()
1069 .any(|tracked| value_text.contains(tracked))
1070 {
1071 return Some((name_text.clone(), name_text, line, true));
1072 }
1073 return None;
1074 }
1075
1076 if tracked_names.iter().any(|tracked| {
1077 value_text == *tracked
1078 || value_text.starts_with(&format!("{}.", tracked))
1079 || value_text.starts_with(&format!("{}[", tracked))
1080 }) {
1081 return Some((name_text, value_text, line, false));
1082 }
1083 None
1084 }
1085 "assignment_expression" | "augmented_assignment_expression" => {
1086 let left = node.child_by_field_name("left")?;
1087 let right = node.child_by_field_name("right")?;
1088 let left_text = trace_node_text(left, source);
1089 let right_text = trace_node_text(right, source);
1090
1091 if tracked_names.iter().any(|tracked| right_text == *tracked) {
1092 return Some((left_text, right_text, line, false));
1093 }
1094 None
1095 }
1096 "assignment" => {
1097 let left = node.child_by_field_name("left")?;
1098 let right = node.child_by_field_name("right")?;
1099 let left_text = trace_node_text(left, source);
1100 let right_text = trace_node_text(right, source);
1101
1102 if tracked_names.iter().any(|tracked| right_text == *tracked) {
1103 return Some((left_text, right_text, line, false));
1104 }
1105 None
1106 }
1107 "let_declaration" | "short_var_declaration" => {
1108 let left = node
1109 .child_by_field_name("pattern")
1110 .or_else(|| node.child_by_field_name("left"))?;
1111 let right = node
1112 .child_by_field_name("value")
1113 .or_else(|| node.child_by_field_name("right"))?;
1114 let left_text = trace_node_text(left, source);
1115 let right_text = trace_node_text(right, source);
1116
1117 if tracked_names.iter().any(|tracked| right_text == *tracked) {
1118 return Some((left_text, right_text, line, false));
1119 }
1120 None
1121 }
1122 _ => None,
1123 }
1124}
1125
1126#[allow(clippy::too_many_arguments)]
1127fn check_call_for_data_flow(
1128 store: &CallGraphStore,
1129 symbol_cache: &SharedSymbolCache,
1130 node: Node<'_>,
1131 source: &str,
1132 current_calls: &[TraceForwardCall],
1133 tracked_names: &[String],
1134 symbol: &str,
1135 rel_file: &str,
1136 max_depth: usize,
1137 current_depth: usize,
1138 hops: &mut Vec<callgraph::DataFlowHop>,
1139 depth_limited: &mut bool,
1140 visited: &mut HashSet<(String, String, String)>,
1141) -> StoreAdapterResult<()> {
1142 let args_node =
1143 find_child_by_kind(node, "arguments").or_else(|| find_child_by_kind(node, "argument_list"));
1144 let Some(args_node) = args_node else {
1145 return Ok(());
1146 };
1147
1148 let mut arg_positions = Vec::new();
1149 let mut arg_idx = 0usize;
1150 let mut cursor = args_node.walk();
1151 if cursor.goto_first_child() {
1152 loop {
1153 let child = cursor.node();
1154 let child_kind = child.kind();
1155 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1156 if !cursor.goto_next_sibling() {
1157 break;
1158 }
1159 continue;
1160 }
1161
1162 let arg_text = trace_node_text(child, source);
1163 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1164 if tracked_names
1165 .iter()
1166 .any(|tracked| arg_text.contains(tracked))
1167 {
1168 hops.push(callgraph::DataFlowHop {
1169 file: rel_file.to_string(),
1170 symbol: symbol.to_string(),
1171 variable: arg_text,
1172 line: child.start_position().row as u32 + 1,
1173 flow_type: "parameter".to_string(),
1174 approximate: true,
1175 });
1176 }
1177 if !cursor.goto_next_sibling() {
1178 break;
1179 }
1180 arg_idx += 1;
1181 continue;
1182 }
1183
1184 if tracked_names.iter().any(|tracked| arg_text == *tracked) {
1185 arg_positions.push((arg_idx, arg_text));
1186 }
1187
1188 arg_idx += 1;
1189 if !cursor.goto_next_sibling() {
1190 break;
1191 }
1192 }
1193 }
1194
1195 if arg_positions.is_empty() {
1196 return Ok(());
1197 }
1198
1199 let matched_call = current_calls
1200 .iter()
1201 .find(|call| call.matches_position(node.start_byte(), node.end_byte()));
1202
1203 match matched_call {
1204 Some(TraceForwardCall::Resolved(site)) => {
1205 let Some(target) = trace_target_node(store, site)? else {
1206 return Ok(());
1207 };
1208 if target.file != rel_file && current_depth + 1 > max_depth {
1209 *depth_limited = true;
1210 return Ok(());
1211 }
1212 let params = target
1213 .signature
1214 .as_deref()
1215 .map(|signature| callgraph::extract_parameters(signature, target.lang))
1216 .unwrap_or_default();
1217 let target_file = store.project_root().join(&target.file);
1218 for (pos, _tracked) in &arg_positions {
1219 if let Some(param_name) = params.get(*pos) {
1220 hops.push(callgraph::DataFlowHop {
1221 file: target.file.clone(),
1222 symbol: target.symbol.clone(),
1223 variable: param_name.clone(),
1224 line: target.line,
1225 flow_type: "parameter".to_string(),
1226 approximate: false,
1227 });
1228 trace_data_inner(
1229 store,
1230 symbol_cache,
1231 &target_file,
1232 &target.symbol,
1233 param_name,
1234 max_depth,
1235 current_depth + 1,
1236 hops,
1237 depth_limited,
1238 visited,
1239 )?;
1240 }
1241 }
1242 }
1243 Some(TraceForwardCall::Unresolved(call)) => {
1244 push_unresolved_parameter_hops(hops, rel_file, &call.symbol, &arg_positions, node);
1245 }
1246 None => {
1247 let (_full_callee, short_callee) = extract_callee_names(node, source);
1248 if let Some(callee_name) = short_callee {
1249 push_unresolved_parameter_hops(hops, rel_file, &callee_name, &arg_positions, node);
1250 }
1251 }
1252 }
1253
1254 Ok(())
1255}
1256
1257fn push_unresolved_parameter_hops(
1258 hops: &mut Vec<callgraph::DataFlowHop>,
1259 rel_file: &str,
1260 callee_name: &str,
1261 arg_positions: &[(usize, String)],
1262 call_node: Node<'_>,
1263) {
1264 for (_pos, tracked) in arg_positions {
1265 hops.push(callgraph::DataFlowHop {
1266 file: rel_file.to_string(),
1267 symbol: callee_name.to_string(),
1268 variable: tracked.clone(),
1269 line: call_node.start_position().row as u32 + 1,
1270 flow_type: "parameter".to_string(),
1271 approximate: true,
1272 });
1273 }
1274}
1275
1276fn trace_target_node(
1277 store: &CallGraphStore,
1278 site: &StoreCallSite,
1279) -> StoreAdapterResult<Option<StoreNode>> {
1280 if let Some(target) = &site.target {
1281 return Ok(Some(target.clone()));
1282 }
1283 resolve_exact_symbol(store, &site.target_file, &site.target_symbol, None)
1284 .map(|resolved| resolved.map(|symbol| symbol.representative))
1285}
1286
1287fn trace_forward_calls_for_nodes(
1288 store: &CallGraphStore,
1289 nodes: &[StoreNode],
1290) -> StoreAdapterResult<Vec<TraceForwardCall>> {
1291 let mut calls = Vec::new();
1292 for node in nodes {
1293 calls.extend(
1294 store
1295 .outgoing_calls_of(node)?
1296 .into_iter()
1297 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1298 .map(TraceForwardCall::Resolved),
1299 );
1300 calls.extend(
1301 store
1302 .resolved_self_calls_of(node)?
1303 .into_iter()
1304 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1305 .map(TraceForwardCall::Resolved),
1306 );
1307 calls.extend(
1308 store
1309 .unresolved_calls_of(node)?
1310 .into_iter()
1311 .map(TraceForwardCall::Unresolved),
1312 );
1313 }
1314 calls.sort_by(|left, right| {
1315 left.byte_start()
1316 .cmp(&right.byte_start())
1317 .then(left.byte_end().cmp(&right.byte_end()))
1318 .then(left.line().cmp(&right.line()))
1319 });
1320 Ok(calls)
1321}
1322
1323fn resolve_symbol_query_with_cache(
1324 file: &Path,
1325 symbol: &str,
1326 symbol_cache: &SharedSymbolCache,
1327) -> StoreAdapterResult<String> {
1328 let mut parser = FileParser::with_symbol_cache(symbol_cache.clone());
1329 let symbols = parser.extract_symbols(file)?;
1330 let candidates = symbol_query_candidates_from_symbols(&symbols, symbol);
1331 match candidates.as_slice() {
1332 [candidate] => Ok(candidate.clone()),
1333 [] => Err(AftError::SymbolNotFound {
1334 name: symbol.to_string(),
1335 file: file.display().to_string(),
1336 }
1337 .into()),
1338 _ => Err(AftError::AmbiguousSymbol {
1339 name: symbol.to_string(),
1340 candidates,
1341 }
1342 .into()),
1343 }
1344}
1345
1346fn symbol_query_candidates_from_symbols(symbols: &[Symbol], symbol_name: &str) -> Vec<String> {
1347 let mut seen = HashSet::new();
1348 let mut candidates = Vec::new();
1349 let qualified_query = symbol_name.contains("::");
1350
1351 let mut consider = |candidate: String| {
1352 let matches = if qualified_query {
1353 candidate == symbol_name
1354 } else {
1355 candidate == symbol_name || unqualified_name(&candidate) == symbol_name
1356 };
1357 if matches && seen.insert(candidate.clone()) {
1358 candidates.push(candidate);
1359 }
1360 };
1361
1362 for symbol in symbols {
1363 consider(symbol_identity_from_cache(symbol));
1364 if symbol.exported {
1365 consider(symbol.name.clone());
1366 }
1367 }
1368
1369 candidates.sort();
1370 candidates
1371}
1372
1373fn symbol_identity_from_cache(symbol: &Symbol) -> String {
1374 if symbol.scope_chain.is_empty() {
1375 symbol.name.clone()
1376 } else {
1377 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
1378 }
1379}
1380
1381fn trace_node_text(node: Node<'_>, source: &str) -> String {
1382 source[node.start_byte()..node.end_byte()].to_string()
1383}
1384
1385fn find_node_covering_range(root: Node<'_>, start: usize, end: usize) -> Option<Node<'_>> {
1386 let mut best = None;
1387 let mut cursor = root.walk();
1388
1389 fn walk_covering<'a>(
1390 cursor: &mut tree_sitter::TreeCursor<'a>,
1391 start: usize,
1392 end: usize,
1393 best: &mut Option<Node<'a>>,
1394 ) {
1395 let node = cursor.node();
1396 if node.start_byte() <= start && node.end_byte() >= end {
1397 *best = Some(node);
1398 if cursor.goto_first_child() {
1399 loop {
1400 walk_covering(cursor, start, end, best);
1401 if !cursor.goto_next_sibling() {
1402 break;
1403 }
1404 }
1405 cursor.goto_parent();
1406 }
1407 }
1408 }
1409
1410 walk_covering(&mut cursor, start, end, &mut best);
1411 best
1412}
1413
1414fn find_child_by_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1415 let mut cursor = node.walk();
1416 if cursor.goto_first_child() {
1417 loop {
1418 if cursor.node().kind() == kind {
1419 return Some(cursor.node());
1420 }
1421 if !cursor.goto_next_sibling() {
1422 break;
1423 }
1424 }
1425 }
1426 None
1427}
1428
1429fn extract_callee_names(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) {
1430 let Some(callee) = node.child_by_field_name("function") else {
1431 return (None, None);
1432 };
1433 let full = trace_node_text(callee, source);
1434 let short = if full.contains('.') {
1435 full.rsplit('.').next().unwrap_or(&full).to_string()
1436 } else {
1437 full.clone()
1438 };
1439 (Some(full), Some(short))
1440}
1441
1442pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
1443 match error {
1444 CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
1445 CallGraphStoreError::Unavailable(message) => Response::error(
1446 req_id,
1447 "callgraph_unavailable",
1448 format!("{operation}: persisted callgraph store unavailable: {message}"),
1449 ),
1450 CallGraphStoreError::StaleFiles(files) => Response::error(
1451 req_id,
1452 "callgraph_stale",
1453 format!(
1454 "{operation}: persisted callgraph store has stale files: {}",
1455 files.join(", ")
1456 ),
1457 ),
1458 other => Response::error(
1459 req_id,
1460 "callgraph_store_error",
1461 format!("{operation}: persisted callgraph store error: {other}"),
1462 ),
1463 }
1464}
1465
1466pub fn building_response(req_id: &str, operation: &str) -> Response {
1470 Response::error(
1471 req_id,
1472 "callgraph_building",
1473 format!("{operation}: callgraph store is building in the background; retry shortly"),
1474 )
1475}
1476
1477pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1478 let message = if worktree {
1479 format!(
1480 "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1481 )
1482 } else {
1483 format!("{operation}: project not configured — send 'configure' first")
1484 };
1485 let code = if worktree {
1486 "callgraph_unavailable"
1487 } else {
1488 "not_configured"
1489 };
1490 Response::error(req_id, code, message)
1491}
1492
1493fn resolve_symbol_query(
1494 store: &CallGraphStore,
1495 file: &Path,
1496 symbol: &str,
1497) -> StoreAdapterResult<ResolvedStoreSymbol> {
1498 let nodes = store.nodes_for(file, symbol)?;
1499 collapse_symbol_nodes(store, file, symbol, nodes)
1500}
1501
1502fn resolve_exact_symbol(
1503 store: &CallGraphStore,
1504 file: &str,
1505 symbol: &str,
1506 fallback: Option<StoreNode>,
1507) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1508 let nodes = store
1509 .nodes_for(Path::new(file), symbol)?
1510 .into_iter()
1511 .filter(|node| node.symbol == symbol)
1512 .collect::<Vec<_>>();
1513 if nodes.is_empty() {
1514 return Ok(fallback.map(|node| ResolvedStoreSymbol {
1515 representative: node.clone(),
1516 nodes: vec![node],
1517 }));
1518 }
1519 Ok(Some(collapse_exact_nodes(nodes)))
1520}
1521
1522fn collapse_symbol_nodes(
1523 store: &CallGraphStore,
1524 file: &Path,
1525 query: &str,
1526 nodes: Vec<StoreNode>,
1527) -> StoreAdapterResult<ResolvedStoreSymbol> {
1528 let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1529 for node in nodes {
1530 by_symbol.entry(node.symbol.clone()).or_default().push(node);
1531 }
1532
1533 match by_symbol.len() {
1534 0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1535 name: query.to_string(),
1536 file: display_file_for_error(store, file),
1537 })),
1538 1 => Ok(collapse_exact_nodes(
1539 by_symbol.into_values().next().unwrap_or_default(),
1540 )),
1541 _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1542 name: query.to_string(),
1543 candidates: by_symbol.into_keys().collect(),
1544 })),
1545 }
1546}
1547
1548fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1549 nodes.sort_by(|left, right| {
1550 left.symbol
1551 .cmp(&right.symbol)
1552 .then(left.line.cmp(&right.line))
1553 .then(left.end_line.cmp(&right.end_line))
1554 });
1555 let representative = nodes[0].clone();
1556 ResolvedStoreSymbol {
1557 representative,
1558 nodes,
1559 }
1560}
1561
1562#[allow(clippy::too_many_arguments)]
1563fn collect_callers_recursive(
1564 store: &CallGraphStore,
1565 file: &str,
1566 symbol: &str,
1567 max_depth: usize,
1568 current_depth: usize,
1569 visited: &mut HashSet<(String, String)>,
1570 result: &mut Vec<StoreCallSite>,
1571 depth_limited: &mut bool,
1572 truncated: &mut usize,
1573) -> StoreAdapterResult<()> {
1574 if current_depth >= max_depth {
1575 let omitted = dedup_call_site_count(store.direct_callers_of(Path::new(file), symbol)?);
1576 if omitted > 0 {
1577 *depth_limited = true;
1578 *truncated += omitted;
1579 }
1580 return Ok(());
1581 }
1582
1583 if !visited.insert((file.to_string(), symbol.to_string())) {
1584 return Ok(());
1585 }
1586
1587 let sites = store.direct_callers_of(Path::new(file), symbol)?;
1588 for site in sites {
1589 result.push(site.clone());
1590 if current_depth + 1 < max_depth {
1591 collect_callers_recursive(
1592 store,
1593 &site.caller.file,
1594 &site.caller.symbol,
1595 max_depth,
1596 current_depth + 1,
1597 visited,
1598 result,
1599 depth_limited,
1600 truncated,
1601 )?;
1602 } else {
1603 let omitted = dedup_call_site_count(
1604 store.direct_callers_of(Path::new(&site.caller.file), &site.caller.symbol)?,
1605 );
1606 if omitted > 0 {
1607 *depth_limited = true;
1608 *truncated += omitted;
1609 }
1610 }
1611 }
1612 Ok(())
1613}
1614
1615fn call_tree_inner(
1616 store: &CallGraphStore,
1617 current: &ResolvedStoreSymbol,
1618 max_depth: usize,
1619 current_depth: usize,
1620 visited: &mut HashSet<(String, String)>,
1621) -> StoreAdapterResult<StoreCallTreeNode> {
1622 let node = ¤t.representative;
1623 let visit_key = (node.file.clone(), node.symbol.clone());
1624 if visited.contains(&visit_key) {
1625 return Ok(StoreCallTreeNode {
1626 name: node.symbol.clone(),
1627 file: node.file.clone(),
1628 line: node.line,
1629 signature: node.signature.clone(),
1630 resolved: true,
1631 approximate: None,
1632 resolved_by: None,
1633 children: Vec::new(),
1634 depth_limited: false,
1635 truncated: 0,
1636 });
1637 }
1638 visited.insert(visit_key.clone());
1639
1640 let calls = forward_calls_for_nodes(store, ¤t.nodes)?;
1641 let mut children = Vec::new();
1642 let mut depth_limited = false;
1643 let mut truncated = 0usize;
1644
1645 if current_depth < max_depth {
1646 for call in calls {
1647 match call {
1648 ForwardCall::Resolved(site) => {
1649 let resolved = resolve_exact_symbol(
1650 store,
1651 &site.target_file,
1652 &site.target_symbol,
1653 site.target.clone(),
1654 )?;
1655 if let Some(child_symbol) = resolved {
1656 let mut child = call_tree_inner(
1657 store,
1658 &child_symbol,
1659 max_depth,
1660 current_depth + 1,
1661 visited,
1662 )?;
1663 child.approximate = edge_approximate(&site);
1664 child.resolved_by = edge_resolved_by(&site);
1665 depth_limited |= child.depth_limited;
1666 truncated += child.truncated;
1667 children.push(child);
1668 } else {
1669 children.push(StoreCallTreeNode {
1670 name: site.target_symbol.clone(),
1671 file: site.target_file.clone(),
1672 line: site.line,
1673 signature: None,
1674 resolved: false,
1675 approximate: edge_approximate(&site),
1676 resolved_by: edge_resolved_by(&site),
1677 children: Vec::new(),
1678 depth_limited: false,
1679 truncated: 0,
1680 });
1681 }
1682 }
1683 ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
1684 name: call.symbol,
1685 file: call.caller.file,
1686 line: call.line,
1687 signature: None,
1688 resolved: false,
1689 approximate: None,
1690 resolved_by: None,
1691 children: Vec::new(),
1692 depth_limited: false,
1693 truncated: 0,
1694 }),
1695 }
1696 }
1697 } else if !calls.is_empty() {
1698 depth_limited = true;
1699 truncated = calls.len();
1700 }
1701
1702 visited.remove(&visit_key);
1703 Ok(StoreCallTreeNode {
1704 name: node.symbol.clone(),
1705 file: node.file.clone(),
1706 line: node.line,
1707 signature: node.signature.clone(),
1708 resolved: true,
1709 approximate: None,
1710 resolved_by: None,
1711 children,
1712 depth_limited,
1713 truncated,
1714 })
1715}
1716
1717fn forward_calls_for_nodes(
1718 store: &CallGraphStore,
1719 nodes: &[StoreNode],
1720) -> StoreAdapterResult<Vec<ForwardCall>> {
1721 let mut calls = Vec::new();
1722 for node in nodes {
1723 calls.extend(
1724 store
1725 .outgoing_calls_of(node)?
1726 .into_iter()
1727 .map(ForwardCall::Resolved),
1728 );
1729 calls.extend(
1730 store
1731 .unresolved_calls_of(node)?
1732 .into_iter()
1733 .map(ForwardCall::Unresolved),
1734 );
1735 }
1736 calls.sort_by(|left, right| {
1737 left.byte_start()
1738 .cmp(&right.byte_start())
1739 .then(left.line().cmp(&right.line()))
1740 });
1741 let mut seen = BTreeSet::new();
1742 calls.retain(|call| seen.insert(call.call_site_key()));
1743 Ok(calls)
1744}
1745
1746fn forward_resolved_callees(
1747 store: &CallGraphStore,
1748 file: &str,
1749 symbol: &str,
1750) -> StoreAdapterResult<Vec<(StoreNode, EdgeMarker)>> {
1751 let Some(current) = resolve_exact_symbol(store, file, symbol, None)? else {
1752 return Ok(Vec::new());
1753 };
1754 let mut calls = Vec::new();
1755 for node in ¤t.nodes {
1756 calls.extend(store.outgoing_calls_of(node)?);
1757 }
1758 calls = dedup_call_sites(calls);
1759 calls.sort_by(|left, right| {
1760 left.byte_start
1761 .cmp(&right.byte_start)
1762 .then(left.line.cmp(&right.line))
1763 });
1764
1765 let mut callees = Vec::new();
1766 for site in calls {
1767 let resolved = resolve_exact_symbol(
1768 store,
1769 &site.target_file,
1770 &site.target_symbol,
1771 site.target.clone(),
1772 )?;
1773 if let Some(target) = resolved {
1774 callees.push((target.representative, edge_marker(&site)));
1775 }
1776 }
1777 Ok(callees)
1778}
1779
1780fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
1781 let mut seen = HashSet::new();
1782 let mut deduped = Vec::new();
1783 for site in sites {
1784 if seen.insert(call_site_key(&site)) {
1785 deduped.push(site);
1786 }
1787 }
1788 deduped
1789}
1790
1791fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
1792 sites
1793 .into_iter()
1794 .map(|site| call_site_key(&site))
1795 .collect::<HashSet<_>>()
1796 .len()
1797}
1798
1799fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
1800 (
1801 site.caller.file.clone(),
1802 site.line,
1803 site.target_file.clone(),
1804 site.target_symbol.clone(),
1805 )
1806}
1807
1808fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
1809 trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
1810}
1811
1812fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
1813 StoreTraceToSymbolHop {
1814 symbol: node.symbol.clone(),
1815 file: node.file.clone(),
1816 line: node.line,
1817 approximate: edge.approximate,
1818 resolved_by: edge.resolved_by,
1819 }
1820}
1821
1822fn trace_to_symbol_matches_target(
1823 file: &str,
1824 symbol: &str,
1825 to_symbol: &str,
1826 to_file: Option<&str>,
1827) -> bool {
1828 if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
1829 return false;
1830 }
1831 match to_file {
1832 Some(target_file) => file == target_file,
1833 None => true,
1834 }
1835}
1836
1837fn unqualified_name(symbol: &str) -> &str {
1838 symbol.rsplit("::").next().unwrap_or(symbol)
1839}
1840
1841fn read_source_line(path: &Path, line: u32) -> Option<String> {
1842 let source = std::fs::read_to_string(path).ok()?;
1843 source
1844 .lines()
1845 .nth(line.saturating_sub(1) as usize)
1846 .map(|line| line.trim().to_string())
1847}
1848
1849fn display_file_for_error(store: &CallGraphStore, file: &Path) -> String {
1850 absolute_file(store, file).display().to_string()
1851}
1852
1853fn relative_file(store: &CallGraphStore, file: &Path) -> String {
1854 let absolute = absolute_file(store, file);
1855 absolute
1856 .strip_prefix(store.project_root())
1857 .unwrap_or(&absolute)
1858 .to_string_lossy()
1859 .replace('\\', "/")
1860}
1861
1862fn absolute_file(store: &CallGraphStore, file: &Path) -> PathBuf {
1863 let full_path = if file.is_relative() {
1864 store.project_root().join(file)
1865 } else {
1866 file.to_path_buf()
1867 };
1868 std::fs::canonicalize(&full_path).unwrap_or(full_path)
1869}