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