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