Skip to main content

aft/commands/
callgraph_store_adapter.rs

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(&current.node.file), &current.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, &current_file, &current_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, &current.nodes)?;
901
902    // Keep the legacy value-flow posture: parse the current source for body walks
903    // and use the store only for cross-hop call facts.
904    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        &current_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
1466/// The persisted callgraph store is cold-building in the background. The op did
1467/// not block the request thread; the agent should retry shortly. Mirrors how
1468/// semantic search reports a build in progress.
1469pub 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 = &current.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, &current.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 &current.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}