1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::callgraph::{self, TraceToSymbolCandidate};
7use crate::callgraph_store::{
8 CallGraphStore, CallGraphStoreError, StoreCallSite, StoreNode, StoreUnresolvedCall,
9};
10use crate::error::AftError;
11use crate::protocol::Response;
12
13pub type StoreAdapterResult<T> = Result<T, CallGraphStoreError>;
14
15#[derive(Debug, Clone, Default)]
16struct EdgeMarker {
17 approximate: Option<bool>,
18 resolved_by: Option<String>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct StoreCallersResult {
23 pub symbol: String,
24 pub file: String,
25 pub callers: Vec<StoreCallerGroup>,
26 pub total_callers: usize,
27 pub scanned_files: usize,
28 pub depth_limited: bool,
29 pub truncated: usize,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct StoreCallerGroup {
34 pub file: String,
35 pub callers: Vec<StoreCallerEntry>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct StoreCallerEntry {
40 pub symbol: String,
41 pub line: u32,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub approximate: Option<bool>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub resolved_by: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct StoreCallTreeNode {
50 pub name: String,
51 pub file: String,
52 pub line: u32,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub signature: Option<String>,
55 pub resolved: bool,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub approximate: Option<bool>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub resolved_by: Option<String>,
60 pub children: Vec<StoreCallTreeNode>,
61 pub depth_limited: bool,
62 pub truncated: usize,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct StoreImpactResult {
67 pub symbol: String,
68 pub file: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub signature: Option<String>,
71 pub parameters: Vec<String>,
72 pub total_affected: usize,
73 pub affected_files: usize,
74 pub callers: Vec<StoreImpactCaller>,
75 pub depth_limited: bool,
76 pub truncated: usize,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct StoreImpactCaller {
81 pub caller_symbol: String,
82 pub caller_file: String,
83 pub line: u32,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub signature: Option<String>,
86 pub is_entry_point: bool,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub call_expression: Option<String>,
89 pub parameters: Vec<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub approximate: Option<bool>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub resolved_by: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct StoreTraceHop {
98 pub symbol: String,
99 pub file: String,
100 pub line: u32,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub signature: Option<String>,
103 pub is_entry_point: bool,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub approximate: Option<bool>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub resolved_by: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct StoreTracePath {
112 pub hops: Vec<StoreTraceHop>,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct StoreTraceToResult {
117 pub target_symbol: String,
118 pub target_file: String,
119 pub paths: Vec<StoreTracePath>,
120 pub total_paths: usize,
121 pub entry_points_found: usize,
122 pub max_depth_reached: bool,
123 pub truncated_paths: usize,
124}
125
126#[derive(Debug, Clone, Serialize)]
127pub struct StoreTraceToSymbolHop {
128 pub symbol: String,
129 pub file: String,
130 pub line: u32,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub approximate: Option<bool>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub resolved_by: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize)]
138pub struct StoreTraceToSymbolResult {
139 pub path: Option<Vec<StoreTraceToSymbolHop>>,
140 pub complete: bool,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub reason: Option<String>,
143}
144
145enum ForwardCall {
146 Resolved(StoreCallSite),
147 Unresolved(StoreUnresolvedCall),
148}
149
150impl ForwardCall {
151 fn byte_start(&self) -> usize {
152 match self {
153 Self::Resolved(site) => site.byte_start,
154 Self::Unresolved(call) => call.byte_start,
155 }
156 }
157
158 fn line(&self) -> u32 {
159 match self {
160 Self::Resolved(site) => site.line,
161 Self::Unresolved(call) => call.line,
162 }
163 }
164
165 fn call_site_key(&self) -> (String, u32, String) {
166 match self {
167 Self::Resolved(site) => (
168 site.caller.file.clone(),
169 site.line,
170 format!("{}::{}", site.target_file, site.target_symbol),
171 ),
172 Self::Unresolved(call) => (call.caller.file.clone(), call.line, call.symbol.clone()),
173 }
174 }
175}
176
177#[derive(Clone)]
178struct ResolvedStoreSymbol {
179 representative: StoreNode,
180 nodes: Vec<StoreNode>,
181}
182
183#[derive(Clone)]
184struct TraceElem {
185 node: StoreNode,
186 edge: EdgeMarker,
187}
188
189fn edge_marker(site: &StoreCallSite) -> EdgeMarker {
190 if let Some(resolved_by) = site.supplemental_resolution() {
191 EdgeMarker {
192 approximate: Some(site.approximate()),
193 resolved_by: Some(resolved_by.to_string()),
194 }
195 } else {
196 EdgeMarker::default()
197 }
198}
199
200fn edge_approximate(site: &StoreCallSite) -> Option<bool> {
201 site.supplemental_resolution().map(|_| site.approximate())
202}
203
204fn edge_resolved_by(site: &StoreCallSite) -> Option<String> {
205 site.supplemental_resolution().map(ToString::to_string)
206}
207
208pub fn callers_result(
209 store: &CallGraphStore,
210 file: &Path,
211 symbol: &str,
212 depth: usize,
213) -> StoreAdapterResult<StoreCallersResult> {
214 let target = resolve_symbol_query(store, file, symbol)?;
215 let effective_depth = depth.max(1);
216 let mut visited = HashSet::new();
217 let mut sites = Vec::new();
218 let mut depth_limited = false;
219 let mut truncated = 0usize;
220
221 collect_callers_recursive(
222 store,
223 &target.representative.file,
224 &target.representative.symbol,
225 effective_depth,
226 0,
227 &mut visited,
228 &mut sites,
229 &mut depth_limited,
230 &mut truncated,
231 )?;
232
233 let sites = dedup_call_sites(sites);
234 let total_callers = sites.len();
235 let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
236 for site in sites {
237 groups
238 .entry(site.caller.file.clone())
239 .or_default()
240 .push(StoreCallerEntry {
241 symbol: site.caller.symbol.clone(),
242 line: site.line,
243 approximate: edge_approximate(&site),
244 resolved_by: edge_resolved_by(&site),
245 });
246 }
247
248 Ok(StoreCallersResult {
249 symbol: target.representative.symbol,
250 file: target.representative.file,
251 callers: groups
252 .into_iter()
253 .map(|(file, callers)| StoreCallerGroup { file, callers })
254 .collect(),
255 total_callers,
256 scanned_files: store.indexed_file_count()?,
257 depth_limited,
258 truncated,
259 })
260}
261
262pub fn call_tree_result(
263 store: &CallGraphStore,
264 file: &Path,
265 symbol: &str,
266 depth: usize,
267) -> StoreAdapterResult<StoreCallTreeNode> {
268 let target = resolve_symbol_query(store, file, symbol)?;
269 let mut visited = HashSet::new();
270 call_tree_inner(store, &target, depth, 0, &mut visited)
271}
272
273pub fn impact_result(
274 store: &CallGraphStore,
275 file: &Path,
276 symbol: &str,
277 depth: usize,
278) -> StoreAdapterResult<StoreImpactResult> {
279 let target = resolve_symbol_query(store, file, symbol)?;
280 let effective_depth = depth.max(1);
281 let mut visited = HashSet::new();
282 let mut sites = Vec::new();
283 let mut depth_limited = false;
284 let mut truncated = 0usize;
285
286 collect_callers_recursive(
287 store,
288 &target.representative.file,
289 &target.representative.symbol,
290 effective_depth,
291 0,
292 &mut visited,
293 &mut sites,
294 &mut depth_limited,
295 &mut truncated,
296 )?;
297
298 let sites = dedup_call_sites(sites);
299 let target_signature = target.representative.signature.clone();
300 let target_parameters = target_signature
301 .as_deref()
302 .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
303 .unwrap_or_default();
304
305 let mut affected_files = BTreeSet::new();
306 let mut callers = Vec::new();
307 for site in sites {
308 affected_files.insert(site.caller.file.clone());
309 callers.push(StoreImpactCaller {
310 caller_symbol: site.caller.symbol.clone(),
311 caller_file: site.caller.file.clone(),
312 line: site.line,
313 signature: site.caller.signature.clone(),
314 is_entry_point: site.caller.is_entry_point,
315 call_expression: read_source_line(
316 &store.project_root().join(&site.caller.file),
317 site.line,
318 ),
319 parameters: site
320 .caller
321 .signature
322 .as_deref()
323 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
324 .unwrap_or_default(),
325 approximate: edge_approximate(&site),
326 resolved_by: edge_resolved_by(&site),
327 });
328 }
329 callers.sort_by(|left, right| {
330 left.caller_file
331 .cmp(&right.caller_file)
332 .then(left.line.cmp(&right.line))
333 });
334
335 Ok(StoreImpactResult {
336 symbol: target.representative.symbol,
337 file: target.representative.file,
338 signature: target_signature,
339 parameters: target_parameters,
340 total_affected: callers.len(),
341 affected_files: affected_files.len(),
342 callers,
343 depth_limited,
344 truncated,
345 })
346}
347
348pub fn trace_to_result(
349 store: &CallGraphStore,
350 file: &Path,
351 symbol: &str,
352 max_depth: usize,
353) -> StoreAdapterResult<StoreTraceToResult> {
354 let target = resolve_symbol_query(store, file, symbol)?;
355 let effective_max = if max_depth == 0 { 10 } else { max_depth };
356
357 let initial = vec![TraceElem {
358 node: target.representative.clone(),
359 edge: EdgeMarker::default(),
360 }];
361 let mut complete_paths = Vec::new();
362 if target.representative.is_entry_point {
363 complete_paths.push(initial.clone());
364 }
365
366 let mut queue = vec![(initial, 0usize)];
367 let mut max_depth_reached = false;
368 let mut truncated_paths = 0usize;
369
370 while let Some((path, depth)) = queue.pop() {
371 if depth >= effective_max {
372 max_depth_reached = true;
373 continue;
374 }
375 let Some(current) = path.last() else {
376 continue;
377 };
378 let callers = dedup_call_sites(
379 store.direct_callers_of(Path::new(¤t.node.file), ¤t.node.symbol)?,
380 );
381 if callers.is_empty() {
382 if path.len() > 1 {
383 truncated_paths += 1;
384 }
385 continue;
386 }
387
388 let mut has_new_path = false;
389 for site in callers {
390 if path.iter().any(|elem| {
391 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
392 }) {
393 continue;
394 }
395 has_new_path = true;
396 let mut next_path = path.clone();
397 if let Some(current) = next_path.last_mut() {
398 current.edge = edge_marker(&site);
399 }
400 next_path.push(TraceElem {
401 node: site.caller.clone(),
402 edge: EdgeMarker::default(),
403 });
404 if site.caller.is_entry_point {
405 complete_paths.push(next_path.clone());
406 }
407 queue.push((next_path, depth + 1));
408 }
409 if !has_new_path && path.len() > 1 {
410 truncated_paths += 1;
411 }
412 }
413
414 let mut paths: Vec<StoreTracePath> = complete_paths
415 .into_iter()
416 .map(|mut elems| {
417 elems.reverse();
418 let hops = elems
419 .iter()
420 .enumerate()
421 .map(|(index, elem)| StoreTraceHop {
422 symbol: elem.node.symbol.clone(),
423 file: elem.node.file.clone(),
424 line: elem.node.line,
425 signature: elem.node.signature.clone(),
426 is_entry_point: index == 0 && elem.node.is_entry_point,
427 approximate: elem.edge.approximate,
428 resolved_by: elem.edge.resolved_by.clone(),
429 })
430 .collect();
431 StoreTracePath { hops }
432 })
433 .collect();
434 paths.sort_by(|left, right| {
435 let left_entry = left
436 .hops
437 .first()
438 .map(|hop| hop.symbol.as_str())
439 .unwrap_or("");
440 let right_entry = right
441 .hops
442 .first()
443 .map(|hop| hop.symbol.as_str())
444 .unwrap_or("");
445 left_entry
446 .cmp(right_entry)
447 .then(left.hops.len().cmp(&right.hops.len()))
448 });
449 let entry_points_found = paths
450 .iter()
451 .filter_map(|path| path.hops.first())
452 .filter(|hop| hop.is_entry_point)
453 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
454 .collect::<HashSet<_>>()
455 .len();
456
457 Ok(StoreTraceToResult {
458 target_symbol: target.representative.symbol,
459 target_file: target.representative.file,
460 total_paths: paths.len(),
461 paths,
462 entry_points_found,
463 max_depth_reached,
464 truncated_paths,
465 })
466}
467
468pub fn ensure_symbol_resolves(
469 store: &CallGraphStore,
470 file: &Path,
471 symbol: &str,
472) -> StoreAdapterResult<()> {
473 resolve_symbol_query(store, file, symbol).map(|_| ())
474}
475
476pub fn trace_to_symbol_candidates(
477 store: &CallGraphStore,
478 to_symbol: &str,
479) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
480 store.trace_to_symbol_candidates(to_symbol)
481}
482
483pub fn trace_to_symbol_result(
484 store: &CallGraphStore,
485 file: &Path,
486 symbol: &str,
487 to_symbol: &str,
488 to_file: Option<&Path>,
489 max_depth: usize,
490) -> StoreAdapterResult<StoreTraceToSymbolResult> {
491 let origin = resolve_symbol_query(store, file, symbol)?;
492 let target_file = to_file.map(|path| relative_file(store, path));
493 let effective_max = if max_depth == 0 {
494 10
495 } else {
496 max_depth.min(16)
497 };
498
499 let start_hop = trace_to_symbol_hop(&origin.representative);
500 if trace_to_symbol_matches_target(
501 &origin.representative.file,
502 &origin.representative.symbol,
503 to_symbol,
504 target_file.as_deref(),
505 ) {
506 return Ok(StoreTraceToSymbolResult {
507 path: Some(vec![start_hop]),
508 complete: true,
509 reason: None,
510 });
511 }
512
513 let mut queue = VecDeque::new();
514 queue.push_back((
515 origin.representative.file.clone(),
516 origin.representative.symbol.clone(),
517 vec![start_hop],
518 0usize,
519 ));
520 let mut visited = HashSet::new();
521 visited.insert((
522 origin.representative.file.clone(),
523 origin.representative.symbol.clone(),
524 ));
525 let mut max_depth_exhausted = false;
526
527 while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
528 let callees = forward_resolved_callees(store, ¤t_file, ¤t_symbol)?;
529
530 if depth >= effective_max {
531 if callees
532 .iter()
533 .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
534 {
535 max_depth_exhausted = true;
536 }
537 continue;
538 }
539
540 for (callee, edge) in callees {
541 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
542 continue;
543 }
544 let mut next_path = path.clone();
545 next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
546 if trace_to_symbol_matches_target(
547 &callee.file,
548 &callee.symbol,
549 to_symbol,
550 target_file.as_deref(),
551 ) {
552 return Ok(StoreTraceToSymbolResult {
553 path: Some(next_path),
554 complete: true,
555 reason: None,
556 });
557 }
558 queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
559 }
560 }
561
562 if max_depth_exhausted {
563 Ok(StoreTraceToSymbolResult {
564 path: None,
565 complete: false,
566 reason: Some("max_depth_exhausted".to_string()),
567 })
568 } else {
569 Ok(StoreTraceToSymbolResult {
570 path: None,
571 complete: true,
572 reason: Some("no_path_found".to_string()),
573 })
574 }
575}
576
577pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
578 match error {
579 CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
580 CallGraphStoreError::Unavailable(message) => Response::error(
581 req_id,
582 "callgraph_unavailable",
583 format!("{operation}: persisted callgraph store unavailable: {message}"),
584 ),
585 CallGraphStoreError::StaleFiles(files) => Response::error(
586 req_id,
587 "callgraph_stale",
588 format!(
589 "{operation}: persisted callgraph store has stale files: {}",
590 files.join(", ")
591 ),
592 ),
593 other => Response::error(
594 req_id,
595 "callgraph_store_error",
596 format!("{operation}: persisted callgraph store error: {other}"),
597 ),
598 }
599}
600
601pub fn building_response(req_id: &str, operation: &str) -> Response {
605 Response::error(
606 req_id,
607 "callgraph_building",
608 format!("{operation}: callgraph store is building in the background; retry shortly"),
609 )
610}
611
612pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
613 let message = if worktree {
614 format!(
615 "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
616 )
617 } else {
618 format!("{operation}: project not configured — send 'configure' first")
619 };
620 let code = if worktree {
621 "callgraph_unavailable"
622 } else {
623 "not_configured"
624 };
625 Response::error(req_id, code, message)
626}
627
628fn resolve_symbol_query(
629 store: &CallGraphStore,
630 file: &Path,
631 symbol: &str,
632) -> StoreAdapterResult<ResolvedStoreSymbol> {
633 let nodes = store.nodes_for(file, symbol)?;
634 collapse_symbol_nodes(store, file, symbol, nodes)
635}
636
637fn resolve_exact_symbol(
638 store: &CallGraphStore,
639 file: &str,
640 symbol: &str,
641 fallback: Option<StoreNode>,
642) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
643 let nodes = store
644 .nodes_for(Path::new(file), symbol)?
645 .into_iter()
646 .filter(|node| node.symbol == symbol)
647 .collect::<Vec<_>>();
648 if nodes.is_empty() {
649 return Ok(fallback.map(|node| ResolvedStoreSymbol {
650 representative: node.clone(),
651 nodes: vec![node],
652 }));
653 }
654 Ok(Some(collapse_exact_nodes(nodes)))
655}
656
657fn collapse_symbol_nodes(
658 store: &CallGraphStore,
659 file: &Path,
660 query: &str,
661 nodes: Vec<StoreNode>,
662) -> StoreAdapterResult<ResolvedStoreSymbol> {
663 let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
664 for node in nodes {
665 by_symbol.entry(node.symbol.clone()).or_default().push(node);
666 }
667
668 match by_symbol.len() {
669 0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
670 name: query.to_string(),
671 file: display_file_for_error(store, file),
672 })),
673 1 => Ok(collapse_exact_nodes(
674 by_symbol.into_values().next().unwrap_or_default(),
675 )),
676 _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
677 name: query.to_string(),
678 candidates: by_symbol.into_keys().collect(),
679 })),
680 }
681}
682
683fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
684 nodes.sort_by(|left, right| {
685 left.symbol
686 .cmp(&right.symbol)
687 .then(left.line.cmp(&right.line))
688 .then(left.end_line.cmp(&right.end_line))
689 });
690 let representative = nodes[0].clone();
691 ResolvedStoreSymbol {
692 representative,
693 nodes,
694 }
695}
696
697#[allow(clippy::too_many_arguments)]
698fn collect_callers_recursive(
699 store: &CallGraphStore,
700 file: &str,
701 symbol: &str,
702 max_depth: usize,
703 current_depth: usize,
704 visited: &mut HashSet<(String, String)>,
705 result: &mut Vec<StoreCallSite>,
706 depth_limited: &mut bool,
707 truncated: &mut usize,
708) -> StoreAdapterResult<()> {
709 if current_depth >= max_depth {
710 let omitted = dedup_call_site_count(store.direct_callers_of(Path::new(file), symbol)?);
711 if omitted > 0 {
712 *depth_limited = true;
713 *truncated += omitted;
714 }
715 return Ok(());
716 }
717
718 if !visited.insert((file.to_string(), symbol.to_string())) {
719 return Ok(());
720 }
721
722 let sites = store.direct_callers_of(Path::new(file), symbol)?;
723 for site in sites {
724 result.push(site.clone());
725 if current_depth + 1 < max_depth {
726 collect_callers_recursive(
727 store,
728 &site.caller.file,
729 &site.caller.symbol,
730 max_depth,
731 current_depth + 1,
732 visited,
733 result,
734 depth_limited,
735 truncated,
736 )?;
737 } else {
738 let omitted = dedup_call_site_count(
739 store.direct_callers_of(Path::new(&site.caller.file), &site.caller.symbol)?,
740 );
741 if omitted > 0 {
742 *depth_limited = true;
743 *truncated += omitted;
744 }
745 }
746 }
747 Ok(())
748}
749
750fn call_tree_inner(
751 store: &CallGraphStore,
752 current: &ResolvedStoreSymbol,
753 max_depth: usize,
754 current_depth: usize,
755 visited: &mut HashSet<(String, String)>,
756) -> StoreAdapterResult<StoreCallTreeNode> {
757 let node = ¤t.representative;
758 let visit_key = (node.file.clone(), node.symbol.clone());
759 if visited.contains(&visit_key) {
760 return Ok(StoreCallTreeNode {
761 name: node.symbol.clone(),
762 file: node.file.clone(),
763 line: node.line,
764 signature: node.signature.clone(),
765 resolved: true,
766 approximate: None,
767 resolved_by: None,
768 children: Vec::new(),
769 depth_limited: false,
770 truncated: 0,
771 });
772 }
773 visited.insert(visit_key.clone());
774
775 let calls = forward_calls_for_nodes(store, ¤t.nodes)?;
776 let mut children = Vec::new();
777 let mut depth_limited = false;
778 let mut truncated = 0usize;
779
780 if current_depth < max_depth {
781 for call in calls {
782 match call {
783 ForwardCall::Resolved(site) => {
784 let resolved = resolve_exact_symbol(
785 store,
786 &site.target_file,
787 &site.target_symbol,
788 site.target.clone(),
789 )?;
790 if let Some(child_symbol) = resolved {
791 let mut child = call_tree_inner(
792 store,
793 &child_symbol,
794 max_depth,
795 current_depth + 1,
796 visited,
797 )?;
798 child.approximate = edge_approximate(&site);
799 child.resolved_by = edge_resolved_by(&site);
800 depth_limited |= child.depth_limited;
801 truncated += child.truncated;
802 children.push(child);
803 } else {
804 children.push(StoreCallTreeNode {
805 name: site.target_symbol.clone(),
806 file: site.target_file.clone(),
807 line: site.line,
808 signature: None,
809 resolved: false,
810 approximate: edge_approximate(&site),
811 resolved_by: edge_resolved_by(&site),
812 children: Vec::new(),
813 depth_limited: false,
814 truncated: 0,
815 });
816 }
817 }
818 ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
819 name: call.symbol,
820 file: call.caller.file,
821 line: call.line,
822 signature: None,
823 resolved: false,
824 approximate: None,
825 resolved_by: None,
826 children: Vec::new(),
827 depth_limited: false,
828 truncated: 0,
829 }),
830 }
831 }
832 } else if !calls.is_empty() {
833 depth_limited = true;
834 truncated = calls.len();
835 }
836
837 visited.remove(&visit_key);
838 Ok(StoreCallTreeNode {
839 name: node.symbol.clone(),
840 file: node.file.clone(),
841 line: node.line,
842 signature: node.signature.clone(),
843 resolved: true,
844 approximate: None,
845 resolved_by: None,
846 children,
847 depth_limited,
848 truncated,
849 })
850}
851
852fn forward_calls_for_nodes(
853 store: &CallGraphStore,
854 nodes: &[StoreNode],
855) -> StoreAdapterResult<Vec<ForwardCall>> {
856 let mut calls = Vec::new();
857 for node in nodes {
858 calls.extend(
859 store
860 .outgoing_calls_of(node)?
861 .into_iter()
862 .map(ForwardCall::Resolved),
863 );
864 calls.extend(
865 store
866 .unresolved_calls_of(node)?
867 .into_iter()
868 .map(ForwardCall::Unresolved),
869 );
870 }
871 calls.sort_by(|left, right| {
872 left.byte_start()
873 .cmp(&right.byte_start())
874 .then(left.line().cmp(&right.line()))
875 });
876 let mut seen = BTreeSet::new();
877 calls.retain(|call| seen.insert(call.call_site_key()));
878 Ok(calls)
879}
880
881fn forward_resolved_callees(
882 store: &CallGraphStore,
883 file: &str,
884 symbol: &str,
885) -> StoreAdapterResult<Vec<(StoreNode, EdgeMarker)>> {
886 let Some(current) = resolve_exact_symbol(store, file, symbol, None)? else {
887 return Ok(Vec::new());
888 };
889 let mut calls = Vec::new();
890 for node in ¤t.nodes {
891 calls.extend(store.outgoing_calls_of(node)?);
892 }
893 calls = dedup_call_sites(calls);
894 calls.sort_by(|left, right| {
895 left.byte_start
896 .cmp(&right.byte_start)
897 .then(left.line.cmp(&right.line))
898 });
899
900 let mut callees = Vec::new();
901 for site in calls {
902 let resolved = resolve_exact_symbol(
903 store,
904 &site.target_file,
905 &site.target_symbol,
906 site.target.clone(),
907 )?;
908 if let Some(target) = resolved {
909 callees.push((target.representative, edge_marker(&site)));
910 }
911 }
912 Ok(callees)
913}
914
915fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
916 let mut seen = HashSet::new();
917 let mut deduped = Vec::new();
918 for site in sites {
919 if seen.insert(call_site_key(&site)) {
920 deduped.push(site);
921 }
922 }
923 deduped
924}
925
926fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
927 sites
928 .into_iter()
929 .map(|site| call_site_key(&site))
930 .collect::<HashSet<_>>()
931 .len()
932}
933
934fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
935 (
936 site.caller.file.clone(),
937 site.line,
938 site.target_file.clone(),
939 site.target_symbol.clone(),
940 )
941}
942
943fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
944 trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
945}
946
947fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
948 StoreTraceToSymbolHop {
949 symbol: node.symbol.clone(),
950 file: node.file.clone(),
951 line: node.line,
952 approximate: edge.approximate,
953 resolved_by: edge.resolved_by,
954 }
955}
956
957fn trace_to_symbol_matches_target(
958 file: &str,
959 symbol: &str,
960 to_symbol: &str,
961 to_file: Option<&str>,
962) -> bool {
963 if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
964 return false;
965 }
966 match to_file {
967 Some(target_file) => file == target_file,
968 None => true,
969 }
970}
971
972fn unqualified_name(symbol: &str) -> &str {
973 symbol.rsplit("::").next().unwrap_or(symbol)
974}
975
976fn read_source_line(path: &Path, line: u32) -> Option<String> {
977 let source = std::fs::read_to_string(path).ok()?;
978 source
979 .lines()
980 .nth(line.saturating_sub(1) as usize)
981 .map(|line| line.trim().to_string())
982}
983
984fn display_file_for_error(store: &CallGraphStore, file: &Path) -> String {
985 absolute_file(store, file).display().to_string()
986}
987
988fn relative_file(store: &CallGraphStore, file: &Path) -> String {
989 let absolute = absolute_file(store, file);
990 absolute
991 .strip_prefix(store.project_root())
992 .unwrap_or(&absolute)
993 .to_string_lossy()
994 .replace('\\', "/")
995}
996
997fn absolute_file(store: &CallGraphStore, file: &Path) -> PathBuf {
998 let full_path = if file.is_relative() {
999 store.project_root().join(file)
1000 } else {
1001 file.to_path_buf()
1002 };
1003 std::fs::canonicalize(&full_path).unwrap_or(full_path)
1004}