infiniloom_engine/index/query.rs
1//! Call graph query API for analyzing symbol relationships
2//!
3//! This module provides high-level functions for querying call relationships,
4//! dependencies, and references between symbols in a codebase. Perfect for
5//! impact analysis, refactoring support, and understanding code structure.
6//!
7//! # Quick Start
8//!
9//! ```rust
10//! use infiniloom_engine::index::{IndexBuilder, query};
11//!
12//! // Build index for your repository
13//! let mut builder = IndexBuilder::new();
14//! builder.index_directory("/path/to/repo")?;
15//! let (index, graph) = builder.build();
16//!
17//! // Find a symbol by name
18//! let symbols = query::find_symbol(&index, "process_payment");
19//! for symbol in symbols {
20//! println!("Found: {} in {} at line {}",
21//! symbol.name, symbol.file, symbol.line);
22//! }
23//! # Ok::<(), Box<dyn std::error::Error>>(())
24//! ```
25//!
26//! # Finding Symbols
27//!
28//! Search for symbols by name across the entire codebase:
29//!
30//! ```rust
31//! use infiniloom_engine::index::query;
32//!
33//! # let (index, _graph) = setup_test_index();
34//! // Find all symbols with matching name
35//! let symbols = query::find_symbol(&index, "authenticate");
36//!
37//! for symbol in symbols {
38//! println!("{} {} in {}:{}",
39//! symbol.kind, // "function", "method", etc.
40//! symbol.name, // "authenticate"
41//! symbol.file, // "src/auth.rs"
42//! symbol.line // 42
43//! );
44//!
45//! if let Some(sig) = &symbol.signature {
46//! println!(" Signature: {}", sig);
47//! }
48//! }
49//! ```
50//!
51//! # Querying Callers (Who Calls This?)
52//!
53//! Find all functions/methods that call a specific symbol:
54//!
55//! ```rust
56//! use infiniloom_engine::index::query;
57//!
58//! # let (index, graph) = setup_test_index();
59//! // Find who calls "validate_token"
60//! let callers = query::get_callers_by_name(&index, &graph, "validate_token")?;
61//!
62//! println!("Functions that call validate_token:");
63//! for caller in callers {
64//! println!(" - {} in {}:{}",
65//! caller.name, // "check_auth"
66//! caller.file, // "src/middleware.rs"
67//! caller.line // 23
68//! );
69//! }
70//! # Ok::<(), Box<dyn std::error::Error>>(())
71//! ```
72//!
73//! # Querying Callees (What Does This Call?)
74//!
75//! Find all functions/methods called by a specific symbol:
76//!
77//! ```rust
78//! use infiniloom_engine::index::query;
79//!
80//! # let (index, graph) = setup_test_index();
81//! // Find what "process_order" calls
82//! let callees = query::get_callees_by_name(&index, &graph, "process_order")?;
83//!
84//! println!("Functions called by process_order:");
85//! for callee in callees {
86//! println!(" → {} ({})", callee.name, callee.kind);
87//! println!(" Defined in {}:{}", callee.file, callee.line);
88//! }
89//! # Ok::<(), Box<dyn std::error::Error>>(())
90//! ```
91//!
92//! # Analyzing References (Calls, Imports, Inheritance)
93//!
94//! Get all references to a symbol (calls, imports, inheritance, implementations):
95//!
96//! ```rust
97//! use infiniloom_engine::index::query;
98//!
99//! # let (index, graph) = setup_test_index();
100//! // Find all references to "Database" class
101//! let references = query::get_references_by_name(&index, &graph, "Database")?;
102//!
103//! for reference in references {
104//! match reference.kind.as_str() {
105//! "call" => println!("Called by: {}", reference.symbol.name),
106//! "import" => println!("Imported in: {}", reference.symbol.file),
107//! "inherit" => println!("Inherited by: {}", reference.symbol.name),
108//! "implement" => println!("Implemented by: {}", reference.symbol.name),
109//! _ => {}
110//! }
111//! }
112//! # Ok::<(), Box<dyn std::error::Error>>(())
113//! ```
114//!
115//! # Complete Call Graph
116//!
117//! Get the entire call graph for visualization or analysis:
118//!
119//! ```rust
120//! use infiniloom_engine::index::query;
121//!
122//! # let (index, graph) = setup_test_index();
123//! // Get complete call graph
124//! let call_graph = query::get_call_graph(&index, &graph);
125//!
126//! println!("Call Graph Summary:");
127//! println!(" Nodes (symbols): {}", call_graph.stats.total_nodes);
128//! println!(" Edges (calls): {}", call_graph.stats.total_edges);
129//! println!(" Entry points: {}", call_graph.stats.entry_points);
130//!
131//! // Analyze specific edges
132//! for edge in call_graph.edges.iter().take(5) {
133//! println!("{} → {} ({}:{})",
134//! edge.caller,
135//! edge.callee,
136//! edge.file,
137//! edge.line
138//! );
139//! }
140//! ```
141//!
142//! # Filtered Call Graph (Large Codebases)
143//!
144//! For large repositories, filter the call graph to manageable size:
145//!
146//! ```rust
147//! use infiniloom_engine::index::query;
148//!
149//! # let (index, graph) = setup_test_index();
150//! // Get top 100 most important symbols, up to 500 edges
151//! let call_graph = query::get_call_graph_filtered(&index, &graph, 100, 500);
152//!
153//! println!("Filtered Call Graph:");
154//! println!(" Nodes: {} (limited to 100)", call_graph.stats.total_nodes);
155//! println!(" Edges: {} (limited to 500)", call_graph.stats.total_edges);
156//!
157//! // Most important symbols are included first
158//! for node in call_graph.nodes.iter().take(10) {
159//! println!("Top symbol: {} ({}) in {}",
160//! node.name, node.kind, node.file);
161//! }
162//! ```
163//!
164//! # Symbol ID-Based Queries
165//!
166//! Use symbol IDs for faster lookup when you already know the ID:
167//!
168//! ```rust
169//! use infiniloom_engine::index::query;
170//!
171//! # let (index, graph) = setup_test_index();
172//! # let symbol_id = 42;
173//! // Direct lookup by symbol ID (faster than name-based lookup)
174//! let callers = query::get_callers_by_id(&index, &graph, symbol_id)?;
175//! let callees = query::get_callees_by_id(&index, &graph, symbol_id)?;
176//!
177//! println!("Symbol {} has {} callers and {} callees",
178//! symbol_id, callers.len(), callees.len());
179//! # Ok::<(), Box<dyn std::error::Error>>(())
180//! ```
181//!
182//! # Impact Analysis Example
183//!
184//! Practical example: Analyze impact of changing a function:
185//!
186//! ```rust
187//! use infiniloom_engine::index::{IndexBuilder, query};
188//!
189//! # fn analyze_impact() -> Result<(), Box<dyn std::error::Error>> {
190//! // Build index
191//! let mut builder = IndexBuilder::new();
192//! builder.index_directory("/path/to/repo")?;
193//! let (index, graph) = builder.build();
194//!
195//! // Function we want to change
196//! let target = "calculate_price";
197//!
198//! // Find direct callers
199//! let direct_callers = query::get_callers_by_name(&index, &graph, target)?;
200//! println!("Direct impact: {} functions call {}",
201//! direct_callers.len(), target);
202//!
203//! // Find transitive callers (who calls the callers?)
204//! let mut affected = std::collections::HashSet::new();
205//! affected.extend(direct_callers.iter().map(|s| s.id));
206//!
207//! for caller in &direct_callers {
208//! let transitive = query::get_callers_by_id(&index, &graph, caller.id)?;
209//! affected.extend(transitive.iter().map(|s| s.id));
210//! }
211//!
212//! println!("Total impact: {} functions affected", affected.len());
213//!
214//! // Find what the target calls (dependencies to consider)
215//! let dependencies = query::get_callees_by_name(&index, &graph, target)?;
216//! println!("Dependencies: {} functions called by {}",
217//! dependencies.len(), target);
218//!
219//! # Ok(())
220//! # }
221//! ```
222//!
223//! # Performance Characteristics
224//!
225//! - **`find_symbol()`**: O(1) hash lookup, very fast
226//! - **`get_callers_by_name()`**: O(name_lookup + E) where E = number of edges
227//! - **`get_callees_by_name()`**: O(name_lookup + E) where E = number of edges
228//! - **`get_callers_by_id()`**: O(E) - faster than name-based lookup
229//! - **`get_callees_by_id()`**: O(E) - faster than name-based lookup
230//! - **`get_call_graph()`**: O(N + E) where N = nodes, E = edges
231//! - **`get_call_graph_filtered()`**: O(N log N + E) - sorts nodes by importance
232//!
233//! # Deduplication
234//!
235//! All query functions automatically deduplicate results:
236//! - Multiple definitions of the same symbol (overloads, multiple files) are merged
237//! - Results are sorted by file path and line number for consistency
238//!
239//! # Error Handling
240//!
241//! Functions return `Result<Vec<SymbolInfo>, String>` where:
242//! - **Ok(vec)**: Successful query (vec may be empty if no results)
243//! - **Err(msg)**: Symbol not found in index (only for direct ID lookups)
244//!
245//! Name-based queries always succeed, returning empty Vec if symbol not found.
246//!
247//! # Thread Safety
248//!
249//! All query functions are thread-safe and can be called concurrently:
250//! - `SymbolIndex` and `DepGraph` are immutable after construction
251//! - No internal locks or shared mutable state
252//! - Safe to query from multiple threads simultaneously
253
254use super::types::{DepGraph, IndexSymbol, IndexSymbolKind, SymbolIndex, Visibility};
255use serde::Serialize;
256
257#[cfg(test)]
258fn setup_test_index() -> (SymbolIndex, DepGraph) {
259 // Test helper - returns empty index/graph
260 (SymbolIndex::default(), DepGraph::default())
261}
262
263/// Information about a symbol, returned from call graph queries
264#[derive(Debug, Clone, Serialize)]
265pub struct SymbolInfo {
266 /// Symbol ID
267 pub id: u32,
268 /// Symbol name
269 pub name: String,
270 /// Symbol kind (function, class, method, etc.)
271 pub kind: String,
272 /// File path containing the symbol
273 pub file: String,
274 /// Start line number
275 pub line: u32,
276 /// End line number
277 pub end_line: u32,
278 /// Function/method signature
279 pub signature: Option<String>,
280 /// Visibility (public, private, etc.)
281 pub visibility: String,
282}
283
284/// A reference location in the codebase
285#[derive(Debug, Clone, Serialize)]
286pub struct ReferenceInfo {
287 /// Symbol making the reference
288 pub symbol: SymbolInfo,
289 /// Reference kind (call, import, inherit, implement)
290 pub kind: String,
291}
292
293/// An edge in the call graph
294#[derive(Debug, Clone, Serialize)]
295pub struct CallGraphEdge {
296 /// Caller symbol ID
297 pub caller_id: u32,
298 /// Callee symbol ID
299 pub callee_id: u32,
300 /// Caller symbol name
301 pub caller: String,
302 /// Callee symbol name
303 pub callee: String,
304 /// File containing the call site
305 pub file: String,
306 /// Line number of the call
307 pub line: u32,
308}
309
310/// Complete call graph with nodes and edges
311#[derive(Debug, Clone, Serialize)]
312pub struct CallGraph {
313 /// All symbols (nodes)
314 pub nodes: Vec<SymbolInfo>,
315 /// Call relationships (edges)
316 pub edges: Vec<CallGraphEdge>,
317 /// Summary statistics
318 pub stats: CallGraphStats,
319}
320
321/// Call graph statistics
322#[derive(Debug, Clone, Serialize)]
323pub struct CallGraphStats {
324 /// Total number of symbols
325 pub total_symbols: usize,
326 /// Total number of call edges
327 pub total_calls: usize,
328 /// Number of functions/methods
329 pub functions: usize,
330 /// Number of classes/structs
331 pub classes: usize,
332}
333
334impl SymbolInfo {
335 /// Create SymbolInfo from an IndexSymbol
336 pub fn from_index_symbol(sym: &IndexSymbol, index: &SymbolIndex) -> Self {
337 let file_path = index
338 .get_file_by_id(sym.file_id.as_u32())
339 .map(|f| f.path.clone())
340 .unwrap_or_else(|| "<unknown>".to_owned());
341
342 Self {
343 id: sym.id.as_u32(),
344 name: sym.name.clone(),
345 kind: format_symbol_kind(sym.kind),
346 file: file_path,
347 line: sym.span.start_line,
348 end_line: sym.span.end_line,
349 signature: sym.signature.clone(),
350 visibility: format_visibility(sym.visibility),
351 }
352 }
353}
354
355/// Find a symbol by name and return its info
356///
357/// Deduplicates results by file path and line number to avoid returning
358/// the same symbol multiple times (e.g., export + declaration).
359pub fn find_symbol(index: &SymbolIndex, name: &str) -> Vec<SymbolInfo> {
360 let mut results: Vec<SymbolInfo> = index
361 .find_symbols(name)
362 .into_iter()
363 .map(|sym| SymbolInfo::from_index_symbol(sym, index))
364 .collect();
365
366 // Deduplicate by (file, line) to avoid returning export+declaration as separate entries
367 results.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
368 results.dedup_by(|a, b| a.file == b.file && a.line == b.line);
369
370 results
371}
372
373/// Get all callers of a symbol by name
374///
375/// Returns symbols that call any symbol with the given name.
376pub fn get_callers_by_name(index: &SymbolIndex, graph: &DepGraph, name: &str) -> Vec<SymbolInfo> {
377 let mut callers = Vec::new();
378
379 // Find all symbols with this name
380 for sym in index.find_symbols(name) {
381 let symbol_id = sym.id.as_u32();
382
383 // Get callers from the dependency graph
384 for caller_id in graph.get_callers(symbol_id) {
385 if let Some(caller_sym) = index.get_symbol(caller_id) {
386 callers.push(SymbolInfo::from_index_symbol(caller_sym, index));
387 }
388 }
389 }
390
391 // Deduplicate by symbol ID
392 callers.sort_by_key(|s| s.id);
393 callers.dedup_by_key(|s| s.id);
394
395 callers
396}
397
398/// Get all callees of a symbol by name
399///
400/// Returns symbols that are called by any symbol with the given name.
401pub fn get_callees_by_name(index: &SymbolIndex, graph: &DepGraph, name: &str) -> Vec<SymbolInfo> {
402 let mut callees = Vec::new();
403
404 // Find all symbols with this name
405 for sym in index.find_symbols(name) {
406 let symbol_id = sym.id.as_u32();
407
408 // Get callees from the dependency graph
409 for callee_id in graph.get_callees(symbol_id) {
410 if let Some(callee_sym) = index.get_symbol(callee_id) {
411 callees.push(SymbolInfo::from_index_symbol(callee_sym, index));
412 }
413 }
414 }
415
416 // Deduplicate by symbol ID
417 callees.sort_by_key(|s| s.id);
418 callees.dedup_by_key(|s| s.id);
419
420 callees
421}
422
423/// Get all references to a symbol by name
424///
425/// Returns symbols that reference any symbol with the given name
426/// (includes calls, imports, inheritance, and implementations).
427pub fn get_references_by_name(
428 index: &SymbolIndex,
429 graph: &DepGraph,
430 name: &str,
431) -> Vec<ReferenceInfo> {
432 let mut references = Vec::new();
433
434 // Find all symbols with this name
435 for sym in index.find_symbols(name) {
436 let symbol_id = sym.id.as_u32();
437
438 // Get callers (call references)
439 for caller_id in graph.get_callers(symbol_id) {
440 if let Some(caller_sym) = index.get_symbol(caller_id) {
441 references.push(ReferenceInfo {
442 symbol: SymbolInfo::from_index_symbol(caller_sym, index),
443 kind: "call".to_owned(),
444 });
445 }
446 }
447
448 // Get referencers (symbol_ref - may include imports/inheritance)
449 for ref_id in graph.get_referencers(symbol_id) {
450 if let Some(ref_sym) = index.get_symbol(ref_id) {
451 // Avoid duplicates with callers
452 if !graph.get_callers(symbol_id).contains(&ref_id) {
453 references.push(ReferenceInfo {
454 symbol: SymbolInfo::from_index_symbol(ref_sym, index),
455 kind: "reference".to_owned(),
456 });
457 }
458 }
459 }
460 }
461
462 // Deduplicate by symbol ID
463 references.sort_by_key(|r| r.symbol.id);
464 references.dedup_by_key(|r| r.symbol.id);
465
466 references
467}
468
469/// Get the complete call graph
470///
471/// Returns all symbols (nodes) and call relationships (edges).
472/// For large codebases, consider using `get_call_graph_filtered` with limits.
473pub fn get_call_graph(index: &SymbolIndex, graph: &DepGraph) -> CallGraph {
474 get_call_graph_filtered(index, graph, None, None)
475}
476
477/// Get a filtered call graph
478///
479/// Args:
480/// - `max_nodes`: Optional limit on number of symbols returned
481/// - `max_edges`: Optional limit on number of edges returned
482pub fn get_call_graph_filtered(
483 index: &SymbolIndex,
484 graph: &DepGraph,
485 max_nodes: Option<usize>,
486 max_edges: Option<usize>,
487) -> CallGraph {
488 // Bug #5 fix: When only max_edges is specified, limit nodes to those that appear in edges
489 // This ensures users get a small, focused graph rather than all nodes with limited edges
490
491 // First, collect all edges and apply edge limit
492 let mut edges: Vec<CallGraphEdge> = graph
493 .calls
494 .iter()
495 .filter_map(|&(caller_id, callee_id)| {
496 let caller_sym = index.get_symbol(caller_id)?;
497 let callee_sym = index.get_symbol(callee_id)?;
498
499 let file_path = index
500 .get_file_by_id(caller_sym.file_id.as_u32())
501 .map(|f| f.path.clone())
502 .unwrap_or_else(|| "<unknown>".to_owned());
503
504 Some(CallGraphEdge {
505 caller_id,
506 callee_id,
507 caller: caller_sym.name.clone(),
508 callee: callee_sym.name.clone(),
509 file: file_path,
510 line: caller_sym.span.start_line,
511 })
512 })
513 .collect();
514
515 // Apply edge limit first (before node filtering for more intuitive behavior)
516 if let Some(limit) = max_edges {
517 edges.truncate(limit);
518 }
519
520 // Collect node IDs that appear in the (possibly limited) edges
521 let edge_node_ids: std::collections::HashSet<u32> = edges
522 .iter()
523 .flat_map(|e| [e.caller_id, e.callee_id])
524 .collect();
525
526 // Collect nodes - when max_edges is specified without max_nodes, only include nodes from edges
527 let mut nodes: Vec<SymbolInfo> = if max_edges.is_some() && max_nodes.is_none() {
528 // Only include nodes that appear in the limited edges
529 index
530 .symbols
531 .iter()
532 .filter(|sym| edge_node_ids.contains(&sym.id.as_u32()))
533 .map(|sym| SymbolInfo::from_index_symbol(sym, index))
534 .collect()
535 } else {
536 // Include all nodes, then optionally truncate
537 index
538 .symbols
539 .iter()
540 .map(|sym| SymbolInfo::from_index_symbol(sym, index))
541 .collect()
542 };
543
544 // Apply node limit if specified
545 if let Some(limit) = max_nodes {
546 nodes.truncate(limit);
547
548 // When max_nodes is applied, also filter edges to only include those between limited nodes
549 let node_ids: std::collections::HashSet<u32> = nodes.iter().map(|n| n.id).collect();
550 edges.retain(|e| node_ids.contains(&e.caller_id) && node_ids.contains(&e.callee_id));
551 }
552
553 // Calculate statistics
554 let functions = nodes
555 .iter()
556 .filter(|n| n.kind == "function" || n.kind == "method")
557 .count();
558 let classes = nodes
559 .iter()
560 .filter(|n| n.kind == "class" || n.kind == "struct")
561 .count();
562
563 let stats =
564 CallGraphStats { total_symbols: nodes.len(), total_calls: edges.len(), functions, classes };
565
566 CallGraph { nodes, edges, stats }
567}
568
569/// Get callers of a symbol by its ID
570pub fn get_callers_by_id(index: &SymbolIndex, graph: &DepGraph, symbol_id: u32) -> Vec<SymbolInfo> {
571 graph
572 .get_callers(symbol_id)
573 .into_iter()
574 .filter_map(|id| index.get_symbol(id))
575 .map(|sym| SymbolInfo::from_index_symbol(sym, index))
576 .collect()
577}
578
579/// Get callees of a symbol by its ID
580pub fn get_callees_by_id(index: &SymbolIndex, graph: &DepGraph, symbol_id: u32) -> Vec<SymbolInfo> {
581 graph
582 .get_callees(symbol_id)
583 .into_iter()
584 .filter_map(|id| index.get_symbol(id))
585 .map(|sym| SymbolInfo::from_index_symbol(sym, index))
586 .collect()
587}
588
589// Helper functions
590
591fn format_symbol_kind(kind: IndexSymbolKind) -> String {
592 match kind {
593 IndexSymbolKind::Function => "function",
594 IndexSymbolKind::Method => "method",
595 IndexSymbolKind::Class => "class",
596 IndexSymbolKind::Struct => "struct",
597 IndexSymbolKind::Interface => "interface",
598 IndexSymbolKind::Trait => "trait",
599 IndexSymbolKind::Enum => "enum",
600 IndexSymbolKind::Constant => "constant",
601 IndexSymbolKind::Variable => "variable",
602 IndexSymbolKind::Module => "module",
603 IndexSymbolKind::Import => "import",
604 IndexSymbolKind::Export => "export",
605 IndexSymbolKind::TypeAlias => "type_alias",
606 IndexSymbolKind::Macro => "macro",
607 }
608 .to_owned()
609}
610
611fn format_visibility(vis: Visibility) -> String {
612 match vis {
613 Visibility::Public => "public",
614 Visibility::Private => "private",
615 Visibility::Protected => "protected",
616 Visibility::Internal => "internal",
617 }
618 .to_owned()
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::index::types::{FileEntry, FileId, Language, Span, SymbolId};
625
626 fn create_test_index() -> (SymbolIndex, DepGraph) {
627 let mut index = SymbolIndex::default();
628
629 // Add test file
630 index.files.push(FileEntry {
631 id: FileId::new(0),
632 path: "test.py".to_string(),
633 language: Language::Python,
634 symbols: 0..2,
635 imports: vec![],
636 content_hash: [0u8; 32],
637 lines: 25,
638 tokens: 100,
639 });
640
641 // Add test symbols
642 index.symbols.push(IndexSymbol {
643 id: SymbolId::new(0),
644 name: "main".to_string(),
645 kind: IndexSymbolKind::Function,
646 file_id: FileId::new(0),
647 span: Span { start_line: 1, start_col: 0, end_line: 10, end_col: 0 },
648 signature: Some("def main()".to_string()),
649 parent: None,
650 visibility: Visibility::Public,
651 docstring: None,
652 });
653
654 index.symbols.push(IndexSymbol {
655 id: SymbolId::new(1),
656 name: "helper".to_string(),
657 kind: IndexSymbolKind::Function,
658 file_id: FileId::new(0),
659 span: Span { start_line: 12, start_col: 0, end_line: 20, end_col: 0 },
660 signature: Some("def helper()".to_string()),
661 parent: None,
662 visibility: Visibility::Private,
663 docstring: None,
664 });
665
666 // Build name index
667 index.symbols_by_name.insert("main".to_string(), vec![0]);
668 index.symbols_by_name.insert("helper".to_string(), vec![1]);
669
670 // Create dependency graph with call edge: main -> helper
671 let mut graph = DepGraph::new();
672 graph.add_call(0, 1); // main calls helper
673
674 (index, graph)
675 }
676
677 #[test]
678 fn test_find_symbol() {
679 let (index, _graph) = create_test_index();
680
681 let results = find_symbol(&index, "main");
682 assert_eq!(results.len(), 1);
683 assert_eq!(results[0].name, "main");
684 assert_eq!(results[0].kind, "function");
685 assert_eq!(results[0].file, "test.py");
686 }
687
688 #[test]
689 fn test_get_callers() {
690 let (index, graph) = create_test_index();
691
692 // helper is called by main
693 let callers = get_callers_by_name(&index, &graph, "helper");
694 assert_eq!(callers.len(), 1);
695 assert_eq!(callers[0].name, "main");
696 }
697
698 #[test]
699 fn test_get_callees() {
700 let (index, graph) = create_test_index();
701
702 // main calls helper
703 let callees = get_callees_by_name(&index, &graph, "main");
704 assert_eq!(callees.len(), 1);
705 assert_eq!(callees[0].name, "helper");
706 }
707
708 #[test]
709 fn test_get_call_graph() {
710 let (index, graph) = create_test_index();
711
712 let call_graph = get_call_graph(&index, &graph);
713 assert_eq!(call_graph.nodes.len(), 2);
714 assert_eq!(call_graph.edges.len(), 1);
715 assert_eq!(call_graph.stats.functions, 2);
716
717 // Check edge
718 assert_eq!(call_graph.edges[0].caller, "main");
719 assert_eq!(call_graph.edges[0].callee, "helper");
720 }
721}