1use crate::types::SemanticAnalysis;
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::instrument;
11
12type FunctionTypeInfo = (PathBuf, usize, Vec<String>, Option<String>);
14
15#[derive(Debug, Error)]
16pub enum GraphError {
17 #[error("Symbol not found: {0}")]
18 SymbolNotFound(String),
19}
20
21fn strip_scope_prefix(name: &str) -> &str {
25 if let Some(pos) = name.rfind("::") {
26 &name[pos + 2..]
27 } else if let Some(pos) = name.rfind('.') {
28 &name[pos + 1..]
29 } else {
30 name
31 }
32}
33
34#[derive(Debug, Clone)]
35pub struct CallChain {
36 pub chain: Vec<(String, PathBuf, usize)>,
37}
38
39#[derive(Debug, Clone)]
41pub struct CallGraph {
42 pub callers: HashMap<String, Vec<(PathBuf, usize, String)>>,
44 pub callees: HashMap<String, Vec<(PathBuf, usize, String)>>,
46 pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
48 function_types: HashMap<String, Vec<FunctionTypeInfo>>,
50}
51
52impl CallGraph {
53 pub fn new() -> Self {
54 Self {
55 callers: HashMap::new(),
56 callees: HashMap::new(),
57 definitions: HashMap::new(),
58 function_types: HashMap::new(),
59 }
60 }
61
62 fn count_parameters(params_str: &str) -> usize {
65 if params_str.is_empty() || params_str == "()" {
66 return 0;
67 }
68 let inner = params_str
70 .trim_start_matches('(')
71 .trim_end_matches(')')
72 .trim();
73 if inner.is_empty() {
74 return 0;
75 }
76 inner.split(',').count()
78 }
79
80 fn match_by_type(
84 &self,
85 candidates: &[FunctionTypeInfo],
86 expected_param_count: Option<usize>,
87 expected_return_type: Option<&str>,
88 ) -> Option<usize> {
89 if candidates.is_empty() {
90 return None;
91 }
92
93 if expected_param_count.is_none() && expected_return_type.is_none() {
95 return None;
96 }
97
98 let mut best_idx = 0;
99 let mut best_score = 0;
100
101 for (idx, (_path, _line, params, ret_type)) in candidates.iter().enumerate() {
102 let mut score = 0;
103
104 if let Some(expected_count) = expected_param_count
106 && !params.is_empty()
107 {
108 let actual_count = Self::count_parameters(¶ms[0]);
109 if actual_count == expected_count {
110 score += 2;
111 }
112 }
113
114 if let Some(expected_ret) = expected_return_type
116 && let Some(actual_ret) = ret_type
117 && actual_ret == expected_ret
118 {
119 score += 1;
120 }
121
122 if !params.is_empty() {
124 score += 1;
125 }
126 if ret_type.is_some() {
127 score += 1;
128 }
129
130 if score > best_score {
131 best_score = score;
132 best_idx = idx;
133 }
134 }
135
136 (best_score > 0).then_some(best_idx)
138 }
139
140 fn resolve_callee(
149 &self,
150 callee: &str,
151 call_file: &Path,
152 call_line: usize,
153 arg_count: Option<usize>,
154 definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
155 function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
156 ) -> String {
157 if let Some(defs) = definitions.get(callee) {
159 return self.pick_best_definition(
160 defs,
161 call_file,
162 call_line,
163 arg_count,
164 callee,
165 function_types,
166 );
167 }
168
169 let stripped = strip_scope_prefix(callee);
171 if stripped != callee
172 && let Some(defs) = definitions.get(stripped)
173 {
174 return self.pick_best_definition(
175 defs,
176 call_file,
177 call_line,
178 arg_count,
179 stripped,
180 function_types,
181 );
182 }
183
184 callee.to_string()
186 }
187
188 fn pick_best_definition(
190 &self,
191 defs: &[(PathBuf, usize)],
192 call_file: &Path,
193 call_line: usize,
194 arg_count: Option<usize>,
195 resolved_name: &str,
196 function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
197 ) -> String {
198 let same_file_defs: Vec<_> = defs.iter().filter(|(path, _)| path == call_file).collect();
200
201 if !same_file_defs.is_empty() {
202 if let Some(type_info) = function_types.get(resolved_name) {
204 let same_file_types: Vec<_> = type_info
205 .iter()
206 .filter(|(path, _, _, _)| path == call_file)
207 .cloned()
208 .collect();
209
210 if !same_file_types.is_empty() && same_file_types.len() > 1 {
211 let mut proximity_groups: Vec<Vec<usize>> = vec![];
213 for (idx, (_, def_line, _, _)) in same_file_types.iter().enumerate() {
214 let mut placed = false;
215 for group in &mut proximity_groups {
216 if let Some((_, first_line, _, _)) = same_file_types.get(group[0])
217 && first_line.abs_diff(*def_line) <= 5
218 {
219 group.push(idx);
220 placed = true;
221 break;
222 }
223 }
224 if !placed {
225 proximity_groups.push(vec![idx]);
226 }
227 }
228
229 let closest_group = proximity_groups.iter().min_by_key(|group| {
231 group
232 .iter()
233 .map(|idx| {
234 if let Some((_, def_line, _, _)) = same_file_types.get(*idx) {
235 def_line.abs_diff(call_line)
236 } else {
237 usize::MAX
238 }
239 })
240 .min()
241 .unwrap_or(usize::MAX)
242 });
243
244 if let Some(group) = closest_group {
245 if group.len() > 1 {
247 let candidates: Vec<_> = group
249 .iter()
250 .filter_map(|idx| same_file_types.get(*idx).cloned())
251 .collect();
252 if let Some(_best_idx) =
254 self.match_by_type(&candidates, arg_count, None)
255 {
256 return resolved_name.to_string();
257 }
258 }
259 }
260 }
261 }
262
263 let _best = same_file_defs
265 .iter()
266 .min_by_key(|(_, def_line)| (*def_line).abs_diff(call_line));
267 return resolved_name.to_string();
268 }
269
270 resolved_name.to_string()
272 }
273
274 #[instrument(skip_all)]
275 pub fn build_from_results(
276 results: Vec<(PathBuf, SemanticAnalysis)>,
277 ) -> Result<Self, GraphError> {
278 let mut graph = CallGraph::new();
279
280 for (path, analysis) in &results {
282 for func in &analysis.functions {
283 graph
284 .definitions
285 .entry(func.name.clone())
286 .or_default()
287 .push((path.clone(), func.line));
288 graph
289 .function_types
290 .entry(func.name.clone())
291 .or_default()
292 .push((
293 path.clone(),
294 func.line,
295 func.parameters.clone(),
296 func.return_type.clone(),
297 ));
298 }
299 for class in &analysis.classes {
300 graph
301 .definitions
302 .entry(class.name.clone())
303 .or_default()
304 .push((path.clone(), class.line));
305 graph
306 .function_types
307 .entry(class.name.clone())
308 .or_default()
309 .push((path.clone(), class.line, vec![], None));
310 }
311 }
312
313 for (path, analysis) in &results {
315 for call in &analysis.calls {
316 let resolved_callee = graph.resolve_callee(
317 &call.callee,
318 path,
319 call.line,
320 call.arg_count,
321 &graph.definitions,
322 &graph.function_types,
323 );
324
325 graph.callees.entry(call.caller.clone()).or_default().push((
326 path.clone(),
327 call.line,
328 resolved_callee.clone(),
329 ));
330 graph.callers.entry(resolved_callee).or_default().push((
331 path.clone(),
332 call.line,
333 call.caller.clone(),
334 ));
335 }
336 for reference in &analysis.references {
337 graph
338 .callers
339 .entry(reference.symbol.clone())
340 .or_default()
341 .push((path.clone(), reference.line, "<reference>".to_string()));
342 }
343 }
344
345 let total_edges = graph.callees.values().map(|v| v.len()).sum::<usize>()
346 + graph.callers.values().map(|v| v.len()).sum::<usize>();
347 let file_count = results.len();
348
349 tracing::debug!(
350 definitions = graph.definitions.len(),
351 edges = total_edges,
352 files = file_count,
353 "graph built"
354 );
355
356 Ok(graph)
357 }
358
359 fn find_chains_bfs(
360 &self,
361 symbol: &str,
362 follow_depth: u32,
363 is_incoming: bool,
364 ) -> Result<Vec<CallChain>, GraphError> {
365 let graph_map = if is_incoming {
366 &self.callers
367 } else {
368 &self.callees
369 };
370
371 if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
372 return Err(GraphError::SymbolNotFound(symbol.to_string()));
373 }
374
375 let mut chains = Vec::new();
376 let mut visited = HashSet::new();
377 let mut queue = VecDeque::new();
378 queue.push_back((symbol.to_string(), 0));
379 visited.insert(symbol.to_string());
380
381 while let Some((current, depth)) = queue.pop_front() {
382 if depth > follow_depth {
383 continue;
384 }
385
386 if let Some(neighbors) = graph_map.get(¤t) {
387 for (path, line, neighbor) in neighbors {
388 let mut chain = vec![(current.clone(), path.clone(), *line)];
389 let mut chain_node = neighbor.clone();
390 let mut chain_depth = depth;
391
392 while chain_depth < follow_depth {
393 if let Some(next_neighbors) = graph_map.get(&chain_node) {
394 if let Some((p, l, n)) = next_neighbors.first() {
395 if is_incoming {
396 chain.insert(0, (chain_node.clone(), p.clone(), *l));
397 } else {
398 chain.push((chain_node.clone(), p.clone(), *l));
399 }
400 chain_node = n.clone();
401 chain_depth += 1;
402 } else {
403 break;
404 }
405 } else {
406 break;
407 }
408 }
409
410 if is_incoming {
411 chain.insert(0, (neighbor.clone(), path.clone(), *line));
412 } else {
413 chain.push((neighbor.clone(), path.clone(), *line));
414 }
415 chains.push(CallChain { chain });
416
417 if !visited.contains(neighbor) && depth < follow_depth {
418 visited.insert(neighbor.clone());
419 queue.push_back((neighbor.clone(), depth + 1));
420 }
421 }
422 }
423 }
424
425 Ok(chains)
426 }
427
428 #[instrument(skip(self))]
429 pub fn find_incoming_chains(
430 &self,
431 symbol: &str,
432 follow_depth: u32,
433 ) -> Result<Vec<CallChain>, GraphError> {
434 self.find_chains_bfs(symbol, follow_depth, true)
435 }
436
437 #[instrument(skip(self))]
438 pub fn find_outgoing_chains(
439 &self,
440 symbol: &str,
441 follow_depth: u32,
442 ) -> Result<Vec<CallChain>, GraphError> {
443 self.find_chains_bfs(symbol, follow_depth, false)
444 }
445}
446
447impl Default for CallGraph {
448 fn default() -> Self {
449 Self::new()
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::types::{CallInfo, FunctionInfo};
457
458 fn make_analysis(
459 funcs: Vec<(&str, usize)>,
460 calls: Vec<(&str, &str, usize)>,
461 ) -> SemanticAnalysis {
462 SemanticAnalysis {
463 functions: funcs
464 .into_iter()
465 .map(|(n, l)| FunctionInfo {
466 name: n.to_string(),
467 line: l,
468 end_line: l + 5,
469 parameters: vec![],
470 return_type: None,
471 })
472 .collect(),
473 classes: vec![],
474 imports: vec![],
475 references: vec![],
476 call_frequency: Default::default(),
477 calls: calls
478 .into_iter()
479 .map(|(c, e, l)| CallInfo {
480 caller: c.to_string(),
481 callee: e.to_string(),
482 line: l,
483 column: 0,
484 arg_count: None,
485 })
486 .collect(),
487 assignments: vec![],
488 field_accesses: vec![],
489 }
490 }
491
492 fn make_typed_analysis(
493 funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
494 calls: Vec<(&str, &str, usize, Option<usize>)>,
495 ) -> SemanticAnalysis {
496 SemanticAnalysis {
497 functions: funcs
498 .into_iter()
499 .map(|(n, l, params, ret_type)| FunctionInfo {
500 name: n.to_string(),
501 line: l,
502 end_line: l + 5,
503 parameters: params,
504 return_type: ret_type.map(|s| s.to_string()),
505 })
506 .collect(),
507 classes: vec![],
508 imports: vec![],
509 references: vec![],
510 call_frequency: Default::default(),
511 calls: calls
512 .into_iter()
513 .map(|(c, e, l, arg_count)| CallInfo {
514 caller: c.to_string(),
515 callee: e.to_string(),
516 line: l,
517 column: 0,
518 arg_count,
519 })
520 .collect(),
521 assignments: vec![],
522 field_accesses: vec![],
523 }
524 }
525
526 #[test]
527 fn test_graph_construction() {
528 let analysis = make_analysis(
529 vec![("main", 1), ("foo", 10), ("bar", 20)],
530 vec![("main", "foo", 2), ("foo", "bar", 15)],
531 );
532 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
533 .expect("Failed to build graph");
534 assert!(graph.definitions.contains_key("main"));
535 assert!(graph.definitions.contains_key("foo"));
536 assert_eq!(graph.callees["main"][0].2, "foo");
537 assert_eq!(graph.callers["foo"][0].2, "main");
538 }
539
540 #[test]
541 fn test_find_incoming_chains_depth_zero() {
542 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
543 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
544 .expect("Failed to build graph");
545 assert!(
546 !graph
547 .find_incoming_chains("foo", 0)
548 .expect("Failed to find chains")
549 .is_empty()
550 );
551 }
552
553 #[test]
554 fn test_find_outgoing_chains_depth_zero() {
555 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
556 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
557 .expect("Failed to build graph");
558 assert!(
559 !graph
560 .find_outgoing_chains("main", 0)
561 .expect("Failed to find chains")
562 .is_empty()
563 );
564 }
565
566 #[test]
567 fn test_symbol_not_found() {
568 assert!(
569 CallGraph::new()
570 .find_incoming_chains("nonexistent", 0)
571 .is_err()
572 );
573 }
574
575 #[test]
576 fn test_same_file_preference() {
577 let analysis_a = make_analysis(
581 vec![("main", 1), ("helper", 10)],
582 vec![("main", "helper", 5)],
583 );
584 let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
585
586 let graph = CallGraph::build_from_results(vec![
587 (PathBuf::from("a.rs"), analysis_a),
588 (PathBuf::from("b.rs"), analysis_b),
589 ])
590 .expect("Failed to build graph");
591
592 assert!(graph.callees.contains_key("main"));
594 let main_callees = &graph.callees["main"];
595 assert_eq!(main_callees.len(), 1);
596 assert_eq!(main_callees[0].2, "helper");
597
598 assert_eq!(main_callees[0].0, PathBuf::from("a.rs"));
600
601 assert!(graph.callers.contains_key("helper"));
603 let helper_callers = &graph.callers["helper"];
604 assert!(
605 helper_callers
606 .iter()
607 .any(|(path, _, _)| path == &PathBuf::from("a.rs"))
608 );
609 }
610
611 #[test]
612 fn test_line_proximity() {
613 let analysis = make_analysis(
616 vec![("process", 10), ("process", 50)],
617 vec![("main", "process", 12)],
618 );
619
620 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
621 .expect("Failed to build graph");
622
623 assert!(graph.callees.contains_key("main"));
625 let main_callees = &graph.callees["main"];
626 assert_eq!(main_callees.len(), 1);
627 assert_eq!(main_callees[0].2, "process");
628
629 assert!(graph.callers.contains_key("process"));
631 let process_callers = &graph.callers["process"];
632 assert!(
633 process_callers
634 .iter()
635 .any(|(_, line, caller)| *line == 12 && caller == "main")
636 );
637 }
638
639 #[test]
640 fn test_scope_prefix_stripping() {
641 let analysis = make_analysis(
644 vec![("method", 10)],
645 vec![
646 ("caller1", "self.method", 5),
647 ("caller2", "Type::method", 15),
648 ("caller3", "module::method", 25),
649 ],
650 );
651
652 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
653 .expect("Failed to build graph");
654
655 assert_eq!(graph.callees["caller1"][0].2, "method");
657 assert_eq!(graph.callees["caller2"][0].2, "method");
658 assert_eq!(graph.callees["caller3"][0].2, "method");
659
660 assert!(graph.callers.contains_key("method"));
662 let method_callers = &graph.callers["method"];
663 assert_eq!(method_callers.len(), 3);
664 assert!(
665 method_callers
666 .iter()
667 .any(|(_, _, caller)| caller == "caller1")
668 );
669 assert!(
670 method_callers
671 .iter()
672 .any(|(_, _, caller)| caller == "caller2")
673 );
674 assert!(
675 method_callers
676 .iter()
677 .any(|(_, _, caller)| caller == "caller3")
678 );
679 }
680
681 #[test]
682 fn test_no_same_file_fallback() {
683 let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
686 let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
687
688 let graph = CallGraph::build_from_results(vec![
689 (PathBuf::from("a.rs"), analysis_a),
690 (PathBuf::from("b.rs"), analysis_b),
691 ])
692 .expect("Failed to build graph");
693
694 assert!(graph.callees.contains_key("main"));
696 let main_callees = &graph.callees["main"];
697 assert_eq!(main_callees.len(), 1);
698 assert_eq!(main_callees[0].2, "helper");
699
700 assert!(graph.callers.contains_key("helper"));
702 let helper_callers = &graph.callers["helper"];
703 assert!(
704 helper_callers
705 .iter()
706 .any(|(path, _, caller)| { path == &PathBuf::from("a.rs") && caller == "main" })
707 );
708 }
709
710 #[test]
711 fn test_type_disambiguation_by_params() {
712 let analysis = make_typed_analysis(
717 vec![
718 ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
719 (
720 "process",
721 12,
722 vec!["(x: i32, y: String)".to_string()],
723 Some("String"),
724 ),
725 ("main", 1, vec![], None),
726 ],
727 vec![("main", "process", 11, Some(2))],
728 );
729
730 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
731 .expect("Failed to build graph");
732
733 assert!(graph.callees.contains_key("main"));
735 let main_callees = &graph.callees["main"];
736 assert_eq!(main_callees.len(), 1);
737 assert_eq!(main_callees[0].2, "process");
738
739 assert!(graph.callers.contains_key("process"));
741 let process_callers = &graph.callers["process"];
742 assert!(
743 process_callers
744 .iter()
745 .any(|(_, line, caller)| *line == 11 && caller == "main")
746 );
747 }
748
749 #[test]
750 fn test_type_disambiguation_fallback() {
751 let analysis = make_analysis(
755 vec![("process", 10), ("process", 50), ("main", 1)],
756 vec![("main", "process", 12)],
757 );
758
759 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
760 .expect("Failed to build graph");
761
762 assert!(graph.callees.contains_key("main"));
764 let main_callees = &graph.callees["main"];
765 assert_eq!(main_callees.len(), 1);
766 assert_eq!(main_callees[0].2, "process");
767
768 assert!(graph.callers.contains_key("process"));
770 let process_callers = &graph.callers["process"];
771 assert!(
772 process_callers
773 .iter()
774 .any(|(_, line, caller)| *line == 12 && caller == "main")
775 );
776 }
777}