Skip to main content

code_analyze_core/
graph.rs

1//! Call graph construction and analysis.
2//!
3//! Builds caller and callee relationships from semantic analysis results.
4//! Implements type-aware function matching to disambiguate overloads and name collisions.
5
6use crate::types::{CallEdge, ImplTraitInfo, SemanticAnalysis, SymbolMatchMode};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::{debug, instrument};
11
12/// Type info for a function: (path, line, parameters, `return_type`)
13type FunctionTypeInfo = (PathBuf, usize, Vec<String>, Option<String>);
14
15const MAX_CANDIDATES_IN_ERROR: usize = 20;
16
17fn format_candidates(candidates: &[String]) -> String {
18    if candidates.len() <= MAX_CANDIDATES_IN_ERROR {
19        candidates.join(", ")
20    } else {
21        format!(
22            "{}, (and {} more)",
23            candidates[..MAX_CANDIDATES_IN_ERROR].join(", "),
24            candidates.len() - MAX_CANDIDATES_IN_ERROR
25        )
26    }
27}
28
29#[derive(Debug, Error)]
30#[non_exhaustive]
31pub enum GraphError {
32    #[error("Symbol not found: '{symbol}'. {hint}")]
33    SymbolNotFound { symbol: String, hint: String },
34    #[error(
35        "Multiple candidates matched '{query}': {candidates_display}. Refine the symbol name or use a stricter match_mode.",
36        candidates_display = format_candidates(.candidates)
37    )]
38    MultipleCandidates {
39        query: String,
40        candidates: Vec<String>,
41    },
42}
43
44/// Resolve a symbol name against the set of known symbols using the requested match mode.
45///
46/// Returns:
47/// - `Ok(name)` when exactly one symbol matches.
48/// - `Err(GraphError::SymbolNotFound)` when no symbol matches.
49/// - `Err(GraphError::MultipleCandidates)` when more than one symbol matches.
50pub fn resolve_symbol<'a>(
51    known_symbols: impl Iterator<Item = &'a String>,
52    query: &str,
53    mode: &SymbolMatchMode,
54) -> Result<String, GraphError> {
55    let mut matches: Vec<String> = if matches!(mode, SymbolMatchMode::Exact) {
56        known_symbols
57            .filter(|s| s.as_str() == query)
58            .cloned()
59            .collect()
60    } else {
61        let query_lower = query.to_lowercase();
62        known_symbols
63            .filter(|s| match mode {
64                SymbolMatchMode::Exact => unreachable!(),
65                SymbolMatchMode::Insensitive => s.to_lowercase() == query_lower,
66                SymbolMatchMode::Prefix => s.to_lowercase().starts_with(&query_lower),
67                SymbolMatchMode::Contains => s.to_lowercase().contains(&query_lower),
68            })
69            .cloned()
70            .collect()
71    };
72    matches.sort();
73
74    debug!(
75        query,
76        mode = ?mode,
77        candidate_count = matches.len(),
78        "resolve_symbol"
79    );
80
81    match matches.len() {
82        1 => Ok(matches.into_iter().next().expect("len==1")),
83        0 => {
84            let hint = match mode {
85                SymbolMatchMode::Exact => {
86                    "Try match_mode=insensitive for a case-insensitive search.".to_string()
87                }
88                _ => "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
89            };
90            Err(GraphError::SymbolNotFound {
91                symbol: query.to_string(),
92                hint,
93            })
94        }
95        _ => Err(GraphError::MultipleCandidates {
96            query: query.to_string(),
97            candidates: matches,
98        }),
99    }
100}
101
102/// Resolve a symbol using the `lowercase_index` for O(1) case-insensitive lookup.
103/// For other modes, iterates the `lowercase_index` keys to avoid per-symbol allocations.
104impl CallGraph {
105    pub fn resolve_symbol_indexed(
106        &self,
107        query: &str,
108        mode: &SymbolMatchMode,
109    ) -> Result<String, GraphError> {
110        // Fast path for exact, case-sensitive lookups: O(1) contains_key checks with no
111        // intermediate allocations.
112        if matches!(mode, SymbolMatchMode::Exact) {
113            if self.definitions.contains_key(query)
114                || self.callers.contains_key(query)
115                || self.callees.contains_key(query)
116            {
117                return Ok(query.to_string());
118            }
119            return Err(GraphError::SymbolNotFound {
120                symbol: query.to_string(),
121                hint: "Try match_mode=insensitive for a case-insensitive search.".to_string(),
122            });
123        }
124
125        let query_lower = query.to_lowercase();
126        let mut matches: Vec<String> = {
127            match mode {
128                SymbolMatchMode::Insensitive => {
129                    // O(1) lookup using lowercase_index
130                    if let Some(originals) = self.lowercase_index.get(&query_lower) {
131                        if originals.len() > 1 {
132                            // Multiple originals map to the same lowercase key; report all.
133                            return Err(GraphError::MultipleCandidates {
134                                query: query.to_string(),
135                                candidates: originals.clone(),
136                            });
137                        }
138                        // Exactly one original maps to this lowercase key; return it.
139                        vec![originals[0].clone()]
140                    } else {
141                        vec![]
142                    }
143                }
144                SymbolMatchMode::Prefix => {
145                    // Use .iter() to avoid redundant hash lookup.
146                    self.lowercase_index
147                        .iter()
148                        .filter(|(k, _)| k.starts_with(&query_lower))
149                        .flat_map(|(_, v)| v.iter().cloned())
150                        .collect()
151                }
152                SymbolMatchMode::Contains => {
153                    // Use .iter() to avoid redundant hash lookup.
154                    self.lowercase_index
155                        .iter()
156                        .filter(|(k, _)| k.contains(&query_lower))
157                        .flat_map(|(_, v)| v.iter().cloned())
158                        .collect()
159                }
160                SymbolMatchMode::Exact => unreachable!("handled above"),
161            }
162        };
163        matches.sort();
164        matches.dedup();
165
166        debug!(
167            query,
168            mode = ?mode,
169            candidate_count = matches.len(),
170            "resolve_symbol_indexed"
171        );
172
173        match matches.len() {
174            1 => Ok(matches.into_iter().next().expect("len==1")),
175            0 => Err(GraphError::SymbolNotFound {
176                symbol: query.to_string(),
177                hint: "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
178            }),
179            _ => Err(GraphError::MultipleCandidates {
180                query: query.to_string(),
181                candidates: matches,
182            }),
183        }
184    }
185}
186
187/// Strip scope prefixes from a callee name.
188/// Handles patterns: `self.method` -> `method`, `Type::method` -> `method`, `module::function` -> `function`.
189/// If no prefix is found, returns the original name.
190fn strip_scope_prefix(name: &str) -> &str {
191    if let Some(pos) = name.rfind("::") {
192        &name[pos + 2..]
193    } else if let Some(pos) = name.rfind('.') {
194        &name[pos + 1..]
195    } else {
196        name
197    }
198}
199
200#[derive(Debug, Clone)]
201pub struct InternalCallChain {
202    pub chain: Vec<(String, PathBuf, usize)>,
203}
204
205/// Call graph storing callers, callees, and function definitions.
206#[derive(Debug, Clone)]
207#[non_exhaustive]
208pub struct CallGraph {
209    /// Callers map: `function_name` -> vec of `CallEdge` (one per call site).
210    pub callers: HashMap<String, Vec<CallEdge>>,
211    /// Callees map: `function_name` -> vec of `CallEdge` (one per call site).
212    pub callees: HashMap<String, Vec<CallEdge>>,
213    /// Definitions map: `function_name` -> vec of (`file_path`, `line_number`).
214    pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
215    /// Internal: maps function name to type info for type-aware disambiguation.
216    function_types: HashMap<String, Vec<FunctionTypeInfo>>,
217    /// Index for O(1) case-insensitive symbol lookup: lowercased -> vec of originals.
218    lowercase_index: HashMap<String, Vec<String>>,
219}
220
221impl CallGraph {
222    #[must_use]
223    pub fn new() -> Self {
224        Self {
225            callers: HashMap::new(),
226            callees: HashMap::new(),
227            definitions: HashMap::new(),
228            function_types: HashMap::new(),
229            lowercase_index: HashMap::new(),
230        }
231    }
232
233    /// Resolve a callee name using two strategies:
234    /// 1. Try the raw callee name first in definitions; return it if found.
235    /// 2. Strip any scope prefix (e.g. `Foo::bar` → `bar`) and look up the stripped name; return it if found.
236    ///
237    /// If neither strategy finds a definition, returns the original callee unchanged.
238    ///
239    /// Returns the resolved callee name (which may be the stripped version).
240    fn resolve_callee(
241        callee: &str,
242        _call_file: &Path,
243        _call_line: usize,
244        _arg_count: Option<usize>,
245        definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
246        _function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
247    ) -> String {
248        // Try raw callee name first
249        if let Some(_defs) = definitions.get(callee) {
250            return callee.to_string();
251        }
252
253        // Try stripped name
254        let stripped = strip_scope_prefix(callee);
255        if stripped != callee
256            && let Some(_defs) = definitions.get(stripped)
257        {
258            return stripped.to_string();
259        }
260
261        // No definition found; return the original callee
262        callee.to_string()
263    }
264
265    /// Build a call graph from semantic analysis results and trait implementation info.
266    #[instrument(skip_all)]
267    #[allow(clippy::too_many_lines)]
268    // exhaustive graph construction pass; splitting into subfunctions harms readability
269    // public API; callers expect owned semantics
270    #[allow(clippy::needless_pass_by_value)]
271    pub fn build_from_results(
272        results: Vec<(PathBuf, SemanticAnalysis)>,
273        impl_traits: &[ImplTraitInfo],
274        impl_only: bool,
275    ) -> Result<Self, GraphError> {
276        let mut graph = CallGraph::new();
277
278        // Build definitions and function_types maps first
279        for (path, analysis) in &results {
280            for func in &analysis.functions {
281                graph
282                    .definitions
283                    .entry(func.name.clone())
284                    .or_default()
285                    .push((path.clone(), func.line));
286                graph
287                    .function_types
288                    .entry(func.name.clone())
289                    .or_default()
290                    .push((
291                        path.clone(),
292                        func.line,
293                        func.parameters.clone(),
294                        func.return_type.clone(),
295                    ));
296            }
297            for class in &analysis.classes {
298                graph
299                    .definitions
300                    .entry(class.name.clone())
301                    .or_default()
302                    .push((path.clone(), class.line));
303                graph
304                    .function_types
305                    .entry(class.name.clone())
306                    .or_default()
307                    .push((path.clone(), class.line, vec![], None));
308            }
309        }
310
311        // Process calls with resolved callee names
312        for (path, analysis) in &results {
313            for call in &analysis.calls {
314                let resolved_callee = Self::resolve_callee(
315                    &call.callee,
316                    path,
317                    call.line,
318                    call.arg_count,
319                    &graph.definitions,
320                    &graph.function_types,
321                );
322
323                graph
324                    .callees
325                    .entry(call.caller.clone())
326                    .or_default()
327                    .push(CallEdge {
328                        path: path.clone(),
329                        line: call.line,
330                        neighbor_name: resolved_callee.clone(),
331                        is_impl_trait: false,
332                    });
333                graph
334                    .callers
335                    .entry(resolved_callee)
336                    .or_default()
337                    .push(CallEdge {
338                        path: path.clone(),
339                        line: call.line,
340                        neighbor_name: call.caller.clone(),
341                        is_impl_trait: false,
342                    });
343            }
344            for reference in &analysis.references {
345                graph
346                    .callers
347                    .entry(reference.symbol.clone())
348                    .or_default()
349                    .push(CallEdge {
350                        path: path.clone(),
351                        line: reference.line,
352                        neighbor_name: "<reference>".to_string(),
353                        is_impl_trait: false,
354                    });
355            }
356        }
357
358        // Add explicit caller edges for each impl Trait for Type block.
359        // These represent the implementing type as a caller of the trait, enabling
360        // impl_only filtering to surface trait implementors rather than call sites.
361        for it in impl_traits {
362            graph
363                .callers
364                .entry(it.trait_name.clone())
365                .or_default()
366                .push(CallEdge {
367                    path: it.path.clone(),
368                    line: it.line,
369                    neighbor_name: it.impl_type.clone(),
370                    is_impl_trait: true,
371                });
372        }
373
374        // If impl_only=true, retain only impl-trait caller edges across all nodes.
375        // Callees are never filtered. This ensures traversal and formatting are
376        // consistently restricted to impl-trait edges regardless of follow_depth.
377        if impl_only {
378            for edges in graph.callers.values_mut() {
379                edges.retain(|e| e.is_impl_trait);
380            }
381        }
382
383        // Build lowercase_index for O(1) case-insensitive lookup.
384        // Union of all keys from definitions, callers, and callees.
385        // Group all originals per lowercase key; sort so min() is stable.
386        for key in graph
387            .definitions
388            .keys()
389            .chain(graph.callers.keys())
390            .chain(graph.callees.keys())
391        {
392            graph
393                .lowercase_index
394                .entry(key.to_lowercase())
395                .or_default()
396                .push(key.clone());
397        }
398        for originals in graph.lowercase_index.values_mut() {
399            originals.sort();
400            originals.dedup();
401        }
402
403        let total_edges = graph.callees.values().map(Vec::len).sum::<usize>()
404            + graph.callers.values().map(Vec::len).sum::<usize>();
405        let file_count = results.len();
406
407        tracing::debug!(
408            definitions = graph.definitions.len(),
409            edges = total_edges,
410            files = file_count,
411            impl_only,
412            "graph built"
413        );
414
415        Ok(graph)
416    }
417
418    fn find_chains_bfs(
419        &self,
420        symbol: &str,
421        follow_depth: u32,
422        is_incoming: bool,
423    ) -> Result<Vec<InternalCallChain>, GraphError> {
424        let graph_map = if is_incoming {
425            &self.callers
426        } else {
427            &self.callees
428        };
429
430        if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
431            return Err(GraphError::SymbolNotFound {
432                symbol: symbol.to_string(),
433                hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
434            });
435        }
436
437        let mut chains = Vec::new();
438        let mut visited = HashSet::new();
439        let mut queue = VecDeque::new();
440        queue.push_back((symbol.to_string(), 0));
441        visited.insert(symbol.to_string());
442
443        while let Some((current, depth)) = queue.pop_front() {
444            if depth > follow_depth {
445                continue;
446            }
447
448            if let Some(neighbors) = graph_map.get(&current) {
449                for edge in neighbors {
450                    let path = &edge.path;
451                    let line = edge.line;
452                    let neighbor = &edge.neighbor_name;
453                    // Pre-allocate capacity: chain holds at most follow_depth + 2 entries
454                    // (the BFS node, up to follow_depth intermediate hops, and the neighbor).
455                    // For incoming chains we accumulate in reverse BFS order (focus first, then
456                    // deeper callers) then call reverse() at the end so that:
457                    //   chain[0]    = immediate caller of focus (closest)
458                    //   chain.last() = focus symbol (the BFS start node at depth 0) or the
459                    //                  current BFS node for deeper depth levels.
460                    // For outgoing chains the order is already focus-first.
461                    let mut chain = {
462                        let mut v = Vec::with_capacity(follow_depth as usize + 2);
463                        v.push((current.clone(), path.clone(), line));
464                        v
465                    };
466                    let mut chain_node = neighbor.clone();
467                    let mut chain_depth = depth;
468
469                    while chain_depth < follow_depth {
470                        if let Some(next_neighbors) = graph_map.get(&chain_node) {
471                            if let Some(next_edge) = next_neighbors.first() {
472                                // Advance to the next (deeper) caller before pushing, so that
473                                // for incoming chains the element pushed is the deeper ancestor
474                                // (not chain_node itself, which was already recorded or is the
475                                // immediate neighbor pushed after this loop).
476                                chain_node = next_edge.neighbor_name.clone();
477                                chain.push((
478                                    chain_node.clone(),
479                                    next_edge.path.clone(),
480                                    next_edge.line,
481                                ));
482                                chain_depth += 1;
483                            } else {
484                                break;
485                            }
486                        } else {
487                            break;
488                        }
489                    }
490
491                    if is_incoming {
492                        // Add the immediate neighbor (closest to focus) at the end,
493                        // then reverse so chain[0] = immediate neighbor.
494                        chain.push((neighbor.clone(), path.clone(), line));
495                        chain.reverse();
496                    } else {
497                        chain.push((neighbor.clone(), path.clone(), line));
498                    }
499
500                    debug_assert!(
501                        chain.len() <= follow_depth as usize + 2,
502                        "find_chains_bfs: chain length {} exceeds bound {}",
503                        chain.len(),
504                        follow_depth + 2
505                    );
506
507                    chains.push(InternalCallChain { chain });
508
509                    if !visited.contains(neighbor) && depth < follow_depth {
510                        visited.insert(neighbor.clone());
511                        queue.push_back((neighbor.clone(), depth + 1));
512                    }
513                }
514            }
515        }
516
517        Ok(chains)
518    }
519
520    #[instrument(skip(self))]
521    pub(crate) fn find_incoming_chains(
522        &self,
523        symbol: &str,
524        follow_depth: u32,
525    ) -> Result<Vec<InternalCallChain>, GraphError> {
526        self.find_chains_bfs(symbol, follow_depth, true)
527    }
528
529    #[instrument(skip(self))]
530    pub(crate) fn find_outgoing_chains(
531        &self,
532        symbol: &str,
533        follow_depth: u32,
534    ) -> Result<Vec<InternalCallChain>, GraphError> {
535        self.find_chains_bfs(symbol, follow_depth, false)
536    }
537}
538
539impl Default for CallGraph {
540    fn default() -> Self {
541        Self::new()
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::types::{CallInfo, FunctionInfo};
549
550    fn make_analysis(
551        funcs: Vec<(&str, usize)>,
552        calls: Vec<(&str, &str, usize)>,
553    ) -> SemanticAnalysis {
554        SemanticAnalysis {
555            functions: funcs
556                .into_iter()
557                .map(|(n, l)| FunctionInfo {
558                    name: n.to_string(),
559                    line: l,
560                    end_line: l + 5,
561                    parameters: vec![],
562                    return_type: None,
563                })
564                .collect(),
565            classes: vec![],
566            imports: vec![],
567            references: vec![],
568            call_frequency: Default::default(),
569            calls: calls
570                .into_iter()
571                .map(|(c, e, l)| CallInfo {
572                    caller: c.to_string(),
573                    callee: e.to_string(),
574                    line: l,
575                    column: 0,
576                    arg_count: None,
577                })
578                .collect(),
579            impl_traits: vec![],
580        }
581    }
582
583    fn make_typed_analysis(
584        funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
585        calls: Vec<(&str, &str, usize, Option<usize>)>,
586    ) -> SemanticAnalysis {
587        SemanticAnalysis {
588            functions: funcs
589                .into_iter()
590                .map(|(n, l, params, ret_type)| FunctionInfo {
591                    name: n.to_string(),
592                    line: l,
593                    end_line: l + 5,
594                    parameters: params,
595                    return_type: ret_type.map(|s| s.to_string()),
596                })
597                .collect(),
598            classes: vec![],
599            imports: vec![],
600            references: vec![],
601            call_frequency: Default::default(),
602            calls: calls
603                .into_iter()
604                .map(|(c, e, l, arg_count)| CallInfo {
605                    caller: c.to_string(),
606                    callee: e.to_string(),
607                    line: l,
608                    column: 0,
609                    arg_count,
610                })
611                .collect(),
612            impl_traits: vec![],
613        }
614    }
615
616    #[test]
617    fn test_graph_construction() {
618        let analysis = make_analysis(
619            vec![("main", 1), ("foo", 10), ("bar", 20)],
620            vec![("main", "foo", 2), ("foo", "bar", 15)],
621        );
622        let graph =
623            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
624                .expect("Failed to build graph");
625        assert!(graph.definitions.contains_key("main"));
626        assert!(graph.definitions.contains_key("foo"));
627        assert_eq!(graph.callees["main"][0].neighbor_name, "foo");
628        assert_eq!(graph.callers["foo"][0].neighbor_name, "main");
629    }
630
631    #[test]
632    fn test_find_incoming_chains_depth_zero() {
633        let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
634        let graph =
635            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
636                .expect("Failed to build graph");
637        assert!(
638            !graph
639                .find_incoming_chains("foo", 0)
640                .expect("Failed to find chains")
641                .is_empty()
642        );
643    }
644
645    #[test]
646    fn test_find_outgoing_chains_depth_zero() {
647        let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
648        let graph =
649            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
650                .expect("Failed to build graph");
651        assert!(
652            !graph
653                .find_outgoing_chains("main", 0)
654                .expect("Failed to find chains")
655                .is_empty()
656        );
657    }
658
659    #[test]
660    fn test_symbol_not_found() {
661        assert!(
662            CallGraph::new()
663                .find_incoming_chains("nonexistent", 0)
664                .is_err()
665        );
666    }
667
668    #[test]
669    fn test_same_file_preference() {
670        // Two files each define "helper". File a.rs has a call from "main" to "helper".
671        // Assert that the graph's callees for "main" point to "helper" and the callers
672        // for "helper" include an entry from a.rs (not b.rs).
673        let analysis_a = make_analysis(
674            vec![("main", 1), ("helper", 10)],
675            vec![("main", "helper", 5)],
676        );
677        let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
678
679        let graph = CallGraph::build_from_results(
680            vec![
681                (PathBuf::from("a.rs"), analysis_a),
682                (PathBuf::from("b.rs"), analysis_b),
683            ],
684            &[],
685            false,
686        )
687        .expect("Failed to build graph");
688
689        // Check that main calls helper
690        assert!(graph.callees.contains_key("main"));
691        let main_callees = &graph.callees["main"];
692        assert_eq!(main_callees.len(), 1);
693        assert_eq!(main_callees[0].neighbor_name, "helper");
694
695        // Check that the call is from a.rs (same file as main)
696        assert_eq!(main_callees[0].path, PathBuf::from("a.rs"));
697
698        // Check that helper has a caller from a.rs
699        assert!(graph.callers.contains_key("helper"));
700        let helper_callers = &graph.callers["helper"];
701        assert!(
702            helper_callers
703                .iter()
704                .any(|e| e.path == PathBuf::from("a.rs"))
705        );
706    }
707
708    #[test]
709    fn test_line_proximity() {
710        // One file with "process" defined at line 10 and line 50, and a call at line 12.
711        // Assert resolution picks the definition at line 10 (closest).
712        let analysis = make_analysis(
713            vec![("process", 10), ("process", 50)],
714            vec![("main", "process", 12)],
715        );
716
717        let graph =
718            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
719                .expect("Failed to build graph");
720
721        // Check that main calls process
722        assert!(graph.callees.contains_key("main"));
723        let main_callees = &graph.callees["main"];
724        assert_eq!(main_callees.len(), 1);
725        assert_eq!(main_callees[0].neighbor_name, "process");
726
727        // Check that process has a caller from main at line 12
728        assert!(graph.callers.contains_key("process"));
729        let process_callers = &graph.callers["process"];
730        assert!(
731            process_callers
732                .iter()
733                .any(|e| e.line == 12 && e.neighbor_name == "main")
734        );
735    }
736
737    #[test]
738    fn test_scope_prefix_stripping() {
739        // One file defines "method" at line 10. Calls use "self.method", "Type::method".
740        // Assert these resolve to "method" in the graph.
741        let analysis = make_analysis(
742            vec![("method", 10)],
743            vec![
744                ("caller1", "self.method", 5),
745                ("caller2", "Type::method", 15),
746                ("caller3", "module::method", 25),
747            ],
748        );
749
750        let graph =
751            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
752                .expect("Failed to build graph");
753
754        // Check that all three callers have "method" as their callee
755        assert_eq!(graph.callees["caller1"][0].neighbor_name, "method");
756        assert_eq!(graph.callees["caller2"][0].neighbor_name, "method");
757        assert_eq!(graph.callees["caller3"][0].neighbor_name, "method");
758
759        // Check that method has three callers
760        assert!(graph.callers.contains_key("method"));
761        let method_callers = &graph.callers["method"];
762        assert_eq!(method_callers.len(), 3);
763        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller1"));
764        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller2"));
765        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller3"));
766    }
767
768    #[test]
769    fn test_no_same_file_fallback() {
770        // File a.rs calls "helper" but "helper" is only defined in b.rs.
771        // Assert the call still resolves (graph has the edge).
772        let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
773        let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
774
775        let graph = CallGraph::build_from_results(
776            vec![
777                (PathBuf::from("a.rs"), analysis_a),
778                (PathBuf::from("b.rs"), analysis_b),
779            ],
780            &[],
781            false,
782        )
783        .expect("Failed to build graph");
784
785        // Check that main calls helper
786        assert!(graph.callees.contains_key("main"));
787        let main_callees = &graph.callees["main"];
788        assert_eq!(main_callees.len(), 1);
789        assert_eq!(main_callees[0].neighbor_name, "helper");
790
791        // Check that helper has a caller from a.rs
792        assert!(graph.callers.contains_key("helper"));
793        let helper_callers = &graph.callers["helper"];
794        assert!(
795            helper_callers
796                .iter()
797                .any(|e| e.path == PathBuf::from("a.rs") && e.neighbor_name == "main")
798        );
799    }
800
801    #[test]
802    fn test_type_disambiguation_by_params() {
803        // Two functions named 'process' in the same file with different parameter counts.
804        // process(x: i32) at line 10, process(x: i32, y: String) at line 12.
805        // Call from main at line 11 is equidistant from both (1 line away).
806        // Type matching should prefer the 2-param version since arg_count=2.
807        let analysis = make_typed_analysis(
808            vec![
809                ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
810                (
811                    "process",
812                    12,
813                    vec!["(x: i32, y: String)".to_string()],
814                    Some("String"),
815                ),
816                ("main", 1, vec![], None),
817            ],
818            vec![("main", "process", 11, Some(2))],
819        );
820
821        let graph =
822            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
823                .expect("Failed to build graph");
824
825        // Check that main calls process
826        assert!(graph.callees.contains_key("main"));
827        let main_callees = &graph.callees["main"];
828        assert_eq!(main_callees.len(), 1);
829        assert_eq!(main_callees[0].neighbor_name, "process");
830
831        // Check that process has a caller from main at line 11
832        assert!(graph.callers.contains_key("process"));
833        let process_callers = &graph.callers["process"];
834        assert!(
835            process_callers
836                .iter()
837                .any(|e| e.line == 11 && e.neighbor_name == "main")
838        );
839    }
840
841    #[test]
842    fn test_type_disambiguation_fallback() {
843        // Two functions named 'process' with no type info (empty parameters, None return_type).
844        // Call from main at line 12 should resolve using line proximity (no regression).
845        // arg_count=None means type matching won't fire, fallback to line proximity.
846        let analysis = make_analysis(
847            vec![("process", 10), ("process", 50), ("main", 1)],
848            vec![("main", "process", 12)],
849        );
850
851        let graph =
852            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
853                .expect("Failed to build graph");
854
855        // Check that main calls process
856        assert!(graph.callees.contains_key("main"));
857        let main_callees = &graph.callees["main"];
858        assert_eq!(main_callees.len(), 1);
859        assert_eq!(main_callees[0].neighbor_name, "process");
860
861        // Check that process has a caller from main
862        assert!(graph.callers.contains_key("process"));
863        let process_callers = &graph.callers["process"];
864        assert!(
865            process_callers
866                .iter()
867                .any(|e| e.line == 12 && e.neighbor_name == "main")
868        );
869    }
870
871    #[test]
872    fn test_impl_only_filters_to_impl_sites() {
873        // Arrange: WriterImpl implements Write; plain_fn calls write directly.
874        use crate::types::ImplTraitInfo;
875        let analysis = make_analysis(
876            vec![("write", 1), ("plain_fn", 20)],
877            vec![("plain_fn", "write", 22)],
878        );
879        let impl_traits = vec![ImplTraitInfo {
880            trait_name: "Write".to_string(),
881            impl_type: "WriterImpl".to_string(),
882            path: PathBuf::from("test.rs"),
883            line: 10,
884        }];
885
886        // Act: build with impl_only=true
887        let graph = CallGraph::build_from_results(
888            vec![(PathBuf::from("test.rs"), analysis)],
889            &impl_traits,
890            true,
891        )
892        .expect("Failed to build graph");
893
894        // Assert: trait "Write" has WriterImpl as an explicit impl-trait caller edge.
895        let callers = graph
896            .callers
897            .get("Write")
898            .expect("Write must have impl caller");
899        assert_eq!(callers.len(), 1, "only impl-trait caller retained");
900        assert_eq!(callers[0].neighbor_name, "WriterImpl");
901        assert!(
902            callers[0].is_impl_trait,
903            "edge must be tagged is_impl_trait"
904        );
905
906        // Assert: regular call-site callers of "write" are filtered out by impl_only.
907        let write_callers = graph.callers.get("write").map(|v| v.len()).unwrap_or(0);
908        assert_eq!(
909            write_callers, 0,
910            "regular callers filtered when impl_only=true"
911        );
912    }
913
914    #[test]
915    fn test_impl_only_false_is_backward_compatible() {
916        // Arrange: same setup, impl_only=false -- all callers returned.
917        use crate::types::ImplTraitInfo;
918        let analysis = make_analysis(
919            vec![("write", 1), ("WriterImpl", 10), ("plain_fn", 20)],
920            vec![("WriterImpl", "write", 12), ("plain_fn", "write", 22)],
921        );
922        let impl_traits = vec![ImplTraitInfo {
923            trait_name: "Write".to_string(),
924            impl_type: "WriterImpl".to_string(),
925            path: PathBuf::from("test.rs"),
926            line: 10,
927        }];
928
929        // Act: build with impl_only=false
930        let graph = CallGraph::build_from_results(
931            vec![(PathBuf::from("test.rs"), analysis)],
932            &impl_traits,
933            false,
934        )
935        .expect("Failed to build graph");
936
937        // Assert: both call-site callers preserved
938        let callers = graph.callers.get("write").expect("write must have callers");
939        assert_eq!(
940            callers.len(),
941            2,
942            "both call-site callers should be present when impl_only=false"
943        );
944
945        // Assert: impl-trait edge is always present regardless of impl_only
946        let write_impl_callers = graph
947            .callers
948            .get("Write")
949            .expect("Write must have impl caller");
950        assert_eq!(write_impl_callers.len(), 1);
951        assert!(write_impl_callers[0].is_impl_trait);
952    }
953
954    #[test]
955    fn test_impl_only_callees_unaffected() {
956        // Arrange: WriterImpl calls write; impl_only=true should not remove callees.
957        use crate::types::ImplTraitInfo;
958        let analysis = make_analysis(
959            vec![("write", 1), ("WriterImpl", 10)],
960            vec![("WriterImpl", "write", 12)],
961        );
962        let impl_traits = vec![ImplTraitInfo {
963            trait_name: "Write".to_string(),
964            impl_type: "WriterImpl".to_string(),
965            path: PathBuf::from("test.rs"),
966            line: 10,
967        }];
968
969        let graph = CallGraph::build_from_results(
970            vec![(PathBuf::from("test.rs"), analysis)],
971            &impl_traits,
972            true,
973        )
974        .expect("Failed to build graph");
975
976        // Assert: callees of WriterImpl are NOT filtered
977        let callees = graph
978            .callees
979            .get("WriterImpl")
980            .expect("WriterImpl must have callees");
981        assert_eq!(
982            callees.len(),
983            1,
984            "callees must not be filtered by impl_only"
985        );
986        assert_eq!(callees[0].neighbor_name, "write");
987    }
988
989    // ---- resolve_symbol tests ----
990
991    fn known(names: &[&str]) -> Vec<String> {
992        names.iter().map(|s| s.to_string()).collect()
993    }
994
995    #[test]
996    fn test_resolve_symbol_exact_match() {
997        let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
998        let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
999        assert_eq!(result.unwrap(), "parse_config");
1000    }
1001
1002    #[test]
1003    fn test_resolve_symbol_exact_no_match() {
1004        let syms = known(&["ParseConfig"]);
1005        let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
1006        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1007    }
1008
1009    #[test]
1010    fn test_resolve_symbol_insensitive_match() {
1011        let syms = known(&["ParseConfig", "other"]);
1012        let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
1013        assert_eq!(result.unwrap(), "ParseConfig");
1014    }
1015
1016    #[test]
1017    fn test_resolve_symbol_insensitive_no_match() {
1018        let syms = known(&["unrelated"]);
1019        let err =
1020            resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
1021        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1022    }
1023
1024    #[test]
1025    fn test_resolve_symbol_prefix_single() {
1026        let syms = known(&["parse_config", "parse_args", "build"]);
1027        let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
1028        assert_eq!(result.unwrap(), "build");
1029    }
1030
1031    #[test]
1032    fn test_resolve_symbol_prefix_multiple_candidates() {
1033        let syms = known(&["parse_config", "parse_args", "build"]);
1034        let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
1035        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1036        if let GraphError::MultipleCandidates { candidates, .. } = err {
1037            assert_eq!(candidates.len(), 2);
1038        }
1039    }
1040
1041    #[test]
1042    fn test_resolve_symbol_contains_single() {
1043        let syms = known(&["parse_config", "build_artifact"]);
1044        let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
1045        assert_eq!(result.unwrap(), "parse_config");
1046    }
1047
1048    #[test]
1049    fn test_resolve_symbol_contains_no_match() {
1050        let syms = known(&["parse_config", "build_artifact"]);
1051        let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
1052        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1053    }
1054
1055    #[test]
1056    fn test_incoming_chain_order_two_hops() {
1057        // Graph: A calls B calls C.  Focus = C, follow_depth = 2.
1058        //
1059        // Expected chains after reverse():
1060        //   depth-0 chain: [B, A, C]  -- immediate caller first, then outermost, then focus
1061        //   depth-1 chain: [A, B]     -- A calls B
1062        //
1063        // This test pins the ordering so that a missing reverse() or an off-by-one in the
1064        // inner-loop push would be caught: chain[1] must be "A" (outermost), not "B" again.
1065        let analysis = make_analysis(
1066            vec![("A", 1), ("B", 10), ("C", 20)],
1067            vec![("A", "B", 2), ("B", "C", 15)],
1068        );
1069        let graph =
1070            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1071                .expect("Failed to build graph");
1072
1073        let chains = graph
1074            .find_incoming_chains("C", 2)
1075            .expect("Failed to find incoming chains");
1076
1077        assert!(
1078            !chains.is_empty(),
1079            "Expected at least one incoming chain for C"
1080        );
1081
1082        // The 2-hop chain has 3 elements: [immediate_caller, outermost_caller, focus].
1083        let chain = chains
1084            .iter()
1085            .find(|c| c.chain.len() == 3)
1086            .expect("Expected a 3-element chain");
1087
1088        assert_eq!(
1089            chain.chain[0].0, "B",
1090            "chain[0] should be immediate caller B, got {}",
1091            chain.chain[0].0
1092        );
1093        assert_eq!(
1094            chain.chain[1].0, "A",
1095            "chain[1] should be outermost caller A, got {}",
1096            chain.chain[1].0
1097        );
1098        assert_eq!(
1099            chain.chain[2].0, "C",
1100            "chain[2] should be focus node C, got {}",
1101            chain.chain[2].0
1102        );
1103    }
1104
1105    // ---- resolve_symbol_indexed tests ----
1106
1107    #[test]
1108    fn test_insensitive_resolve_via_index() {
1109        // Arrange: build a CallGraph with known symbols
1110        let analysis = make_analysis(
1111            vec![("ParseConfig", 1), ("parse_args", 5)],
1112            vec![("ParseConfig", "parse_args", 10)],
1113        );
1114        let graph =
1115            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1116                .expect("Failed to build graph");
1117
1118        // Act: resolve using insensitive mode via the indexed method
1119        let result = graph
1120            .resolve_symbol_indexed("parseconfig", &SymbolMatchMode::Insensitive)
1121            .expect("Should resolve ParseConfig");
1122
1123        // Assert: O(1) lookup via lowercase_index returns the original symbol
1124        assert_eq!(result, "ParseConfig");
1125    }
1126
1127    #[test]
1128    fn test_prefix_resolve_via_index() {
1129        // Arrange: build a CallGraph with multiple symbols matching a prefix
1130        let analysis = make_analysis(
1131            vec![("parse_config", 1), ("parse_args", 5), ("build", 10)],
1132            vec![],
1133        );
1134        let graph =
1135            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1136                .expect("Failed to build graph");
1137
1138        // Act: resolve using prefix mode via the indexed method
1139        let err = graph
1140            .resolve_symbol_indexed("parse", &SymbolMatchMode::Prefix)
1141            .unwrap_err();
1142
1143        // Assert: multiple candidates found
1144        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1145        if let GraphError::MultipleCandidates { candidates, .. } = err {
1146            assert_eq!(candidates.len(), 2);
1147        }
1148    }
1149
1150    #[test]
1151    fn test_insensitive_case_collision_returns_multiple_candidates() {
1152        // Arrange: two symbols that differ only by case map to the same lowercase key
1153        let analysis = make_analysis(vec![("Foo", 1), ("foo", 5)], vec![("Foo", "foo", 10)]);
1154        let graph =
1155            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1156                .expect("Failed to build graph");
1157
1158        // Act: insensitive lookup for "foo" hits both Foo and foo
1159        let err = graph
1160            .resolve_symbol_indexed("foo", &SymbolMatchMode::Insensitive)
1161            .unwrap_err();
1162
1163        // Assert: MultipleCandidates returned for case collision
1164        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1165        if let GraphError::MultipleCandidates { candidates, .. } = err {
1166            assert_eq!(candidates.len(), 2);
1167        }
1168    }
1169
1170    #[test]
1171    fn test_contains_resolve_via_index() {
1172        // Arrange: symbols where two match the query substring; one does not
1173        let analysis = make_analysis(
1174            vec![("parse_config", 1), ("build_config", 5), ("run", 10)],
1175            vec![],
1176        );
1177        let graph =
1178            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1179                .expect("Failed to build graph");
1180
1181        // Act: resolve using contains mode; "config" matches parse_config and build_config
1182        let err = graph
1183            .resolve_symbol_indexed("config", &SymbolMatchMode::Contains)
1184            .unwrap_err();
1185
1186        // Assert: both matching symbols returned as MultipleCandidates
1187        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1188        if let GraphError::MultipleCandidates { candidates, .. } = err {
1189            let mut sorted = candidates.clone();
1190            sorted.sort();
1191            assert_eq!(sorted, vec!["build_config", "parse_config"]);
1192        }
1193    }
1194}