1use std::collections::HashMap;
12use std::path::PathBuf;
13
14use petgraph::graph::{DiGraph, NodeIndex};
15use petgraph::visit::EdgeRef;
16
17use crate::graph::CodeGraph;
18use crate::graph::edge::{EdgeData, EdgeKind};
19#[cfg(feature = "dataflow")]
20use crate::graph::node::DataGraphNode;
21use crate::graph::node::{ExternalNode, FileNode, NodeData, SymbolNode};
22use crate::language::LangId;
23#[cfg(feature = "dataflow")]
24use crate::model::DataNodeId;
25use crate::model::{FileId, IdGenerator, SnapshotId, Symbol, SymbolId};
26
27#[derive(Debug)]
29pub struct GraphBuilder {
30 graph: DiGraph<NodeData, EdgeData>,
32
33 file_to_index: HashMap<FileId, NodeIndex>,
35
36 symbol_to_index: HashMap<SymbolId, NodeIndex>,
38
39 path_to_file: HashMap<PathBuf, FileId>,
41
42 file_id_gen: IdGenerator<FileId>,
44
45 snapshot_id: SnapshotId,
47
48 edge_normalizer: EdgeNormalizer,
50
51 external_index: HashMap<String, NodeIndex>,
53
54 #[cfg(feature = "dataflow")]
56 data_to_index: HashMap<DataNodeId, NodeIndex>,
57}
58
59#[derive(Debug, Default)]
61struct EdgeNormalizer {
62 seen: std::collections::HashSet<(NodeIndex, NodeIndex, EdgeKind)>,
63}
64
65impl EdgeNormalizer {
66 fn is_new(&mut self, source: NodeIndex, target: NodeIndex, kind: EdgeKind) -> bool {
67 self.seen.insert((source, target, kind))
68 }
69}
70
71impl GraphBuilder {
72 pub fn new(snapshot_id: SnapshotId) -> Self {
74 Self {
75 graph: DiGraph::new(),
76 file_to_index: HashMap::new(),
77 symbol_to_index: HashMap::new(),
78 path_to_file: HashMap::new(),
79 file_id_gen: IdGenerator::new(),
80 snapshot_id,
81 edge_normalizer: EdgeNormalizer::default(),
82 external_index: HashMap::new(),
83 #[cfg(feature = "dataflow")]
84 data_to_index: HashMap::new(),
85 }
86 }
87
88 pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
92 if let Some(&existing_id) = self.path_to_file.get(&path) {
94 return existing_id;
95 }
96
97 let id = self.file_id_gen.next();
98
99 let node = FileNode {
100 id,
101 path: path.clone(),
102 language,
103 snapshot_id: self.snapshot_id,
104 };
105
106 let idx = self.graph.add_node(NodeData::File(node));
107
108 self.file_to_index.insert(id, idx);
109 self.path_to_file.insert(path, id);
110
111 id
112 }
113
114 pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
121 if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
123 return Ok(existing);
124 }
125
126 let file_id = *self
128 .path_to_file
129 .get(&symbol.file_path)
130 .ok_or_else(|| crate::Error::Graph("file must be added before its symbols".into()))?;
131
132 let file_idx = *self
133 .file_to_index
134 .get(&file_id)
135 .ok_or_else(|| crate::Error::Graph("file index must exist".into()))?;
136
137 let node = SymbolNode {
138 id: symbol.id,
139 name: symbol.name.clone(),
140 kind: symbol.kind,
141 file_id,
142 visibility: symbol.visibility,
143 source_range: symbol.source_range.clone(),
144 };
145
146 let sym_idx = self.graph.add_node(NodeData::Symbol(node));
147 self.symbol_to_index.insert(symbol.id, sym_idx);
148
149 self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
151
152 Ok(sym_idx)
153 }
154
155 #[cfg(feature = "dataflow")]
164 pub fn add_data_node(&mut self, data_node: &crate::model::DataNode) -> NodeIndex {
165 if let Some(&existing) = self.data_to_index.get(&data_node.id) {
166 return existing;
167 }
168 let node = DataGraphNode {
169 id: data_node.id,
170 symbol_id: data_node.symbol_id,
171 name: data_node.name.clone(),
172 scope: data_node.scope,
173 type_hint: data_node.type_hint.clone(),
174 source_range: data_node.source_range.clone(),
175 };
176 let idx = self.graph.add_node(NodeData::Data(node));
177 self.data_to_index.insert(data_node.id, idx);
178
179 if let Some(symbol_id) = data_node.symbol_id
180 && let Some(&sym_idx) = self.symbol_to_index.get(&symbol_id)
181 {
182 self.add_edge_internal(sym_idx, idx, EdgeKind::Ownership, 1.0);
183 }
184
185 idx
186 }
187
188 #[cfg(feature = "dataflow")]
190 pub fn add_flow_edge(
191 &mut self,
192 source: crate::model::DataNodeId,
193 target: crate::model::DataNodeId,
194 kind: crate::model::FlowKind,
195 confidence: f32,
196 ) {
197 let Some(&src_idx) = self.data_to_index.get(&source) else {
198 return;
199 };
200 let Some(&tgt_idx) = self.data_to_index.get(&target) else {
201 return;
202 };
203 self.add_edge_internal_with_flow(src_idx, tgt_idx, EdgeKind::Flow, confidence, Some(kind));
204 }
205
206 pub fn add_import(&mut self, from: FileId, to: PathBuf) {
212 let Some(&from_idx) = self.file_to_index.get(&from) else {
213 return; };
215
216 if let Some(&to_id) = self.path_to_file.get(&to) {
218 if let Some(&to_idx) = self.file_to_index.get(&to_id) {
219 self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
220 }
221 return;
222 }
223
224 let raw_path = to.to_string_lossy().to_string();
226 let to_idx = if let Some(&idx) = self.external_index.get(&raw_path) {
227 idx
228 } else {
229 let language = self
231 .path_to_file
232 .iter()
233 .find(|(_, id)| **id == from)
234 .map(|(p, _)| {
235 crate::input::detect_language(p).unwrap_or(crate::language::LangId::Python)
236 })
237 .unwrap_or(crate::language::LangId::Python);
238
239 let node = ExternalNode {
240 raw_path: raw_path.clone(),
241 language,
242 classification: None,
243 };
244 let idx = self.graph.add_node(NodeData::External(node));
245 self.external_index.insert(raw_path, idx);
246 idx
247 };
248
249 self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
250 }
251
252 pub fn add_metacall_load(
254 &mut self,
255 from_path: PathBuf,
256 target_lang: LangId,
257 scripts: &[String],
258 confidence: f64,
259 root: &std::path::Path,
260 ) {
261 let Some(&from_fid) = self.path_to_file.get(&from_path) else {
262 return;
263 };
264 let Some(&from_idx) = self.file_to_index.get(&from_fid) else {
265 return;
266 };
267
268 for script in scripts {
269 let target_path = root.join(script);
270 if let Some(&to_fid) = self.path_to_file.get(&target_path) {
272 if let Some(&to_idx) = self.file_to_index.get(&to_fid) {
273 let edge_data = EdgeData::with_confidence(EdgeKind::Import, confidence as f32);
274 self.graph.add_edge(from_idx, to_idx, edge_data);
275 }
276 } else {
277 let raw_path = script.clone();
279 if let Some(&to_idx) = self.external_index.get(&raw_path) {
280 let edge_data = EdgeData::with_confidence(EdgeKind::Import, confidence as f32);
281 self.graph.add_edge(from_idx, to_idx, edge_data);
282 } else {
283 let node = ExternalNode {
284 raw_path: raw_path.clone(),
285 language: target_lang,
286 classification: None,
287 };
288 let idx = self.graph.add_node(NodeData::External(node));
289 self.external_index.insert(raw_path, idx);
290
291 let edge_data = EdgeData::with_confidence(EdgeKind::Import, confidence as f32);
292 self.graph.add_edge(from_idx, idx, edge_data);
293 }
294 }
295 }
296 }
297
298 #[cfg(feature = "dataflow")]
300 fn add_edge_internal_with_flow(
301 &mut self,
302 source: NodeIndex,
303 target: NodeIndex,
304 kind: EdgeKind,
305 confidence: f32,
306 flow_kind: Option<crate::model::FlowKind>,
307 ) {
308 if !self.edge_normalizer.is_new(source, target, kind) {
309 if let Some(edge_idx) = self.graph.find_edge(source, target) {
310 let edge = &mut self.graph[edge_idx];
311 edge.confidence = edge.confidence.max(confidence);
312 if edge.flow_kind.is_none() {
313 edge.flow_kind = flow_kind;
314 }
315 }
316 return;
317 }
318 let edge_data = EdgeData {
319 kind,
320 confidence,
321 flow_kind,
322 };
323 self.graph.add_edge(source, target, edge_data);
324 }
325
326 pub fn add_reference(&mut self, from: SymbolId, to: SymbolId, confidence: f32) {
332 let Some(&from_idx) = self.symbol_to_index.get(&from) else {
333 return;
334 };
335 let Some(&to_idx) = self.symbol_to_index.get(&to) else {
336 return;
337 };
338
339 self.add_edge_internal(from_idx, to_idx, EdgeKind::Reference, confidence);
340 }
341
342 fn add_edge_internal(
347 &mut self,
348 source: NodeIndex,
349 target: NodeIndex,
350 kind: EdgeKind,
351 confidence: f32,
352 ) {
353 if !self.edge_normalizer.is_new(source, target, kind) {
354 if let Some(edge_idx) = self.graph.find_edge(source, target) {
356 let existing = &mut self.graph[edge_idx];
357 if existing.kind == kind {
358 existing.confidence = existing.confidence.max(confidence);
359 }
360 }
361 return;
362 }
363
364 let edge_data = EdgeData::with_confidence(kind, confidence);
365
366 self.graph.add_edge(source, target, edge_data);
367 }
368
369 pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
371 self.path_to_file.get(path).copied()
372 }
373
374 pub fn import_adjacency(&self) -> HashMap<FileId, Vec<FileId>> {
378 let mut adjacency: HashMap<FileId, Vec<FileId>> = HashMap::new();
379 let mut index_to_file: HashMap<NodeIndex, FileId> = HashMap::new();
380 for (&file_id, &idx) in &self.file_to_index {
381 index_to_file.insert(idx, file_id);
382 }
383 for edge in self.graph.edge_references() {
384 if edge.weight().kind == EdgeKind::Import
385 && let (Some(&from_id), Some(&to_id)) = (
386 index_to_file.get(&edge.source()),
387 index_to_file.get(&edge.target()),
388 )
389 {
390 adjacency.entry(from_id).or_default().push(to_id);
391 }
392 }
393 adjacency
394 }
395
396 pub fn build(self) -> CodeGraph {
398 CodeGraph {
399 graph: self.graph,
400 file_to_index: self.file_to_index,
401 symbol_to_index: self.symbol_to_index,
402 external_index: self.external_index,
403 snapshot_id: self.snapshot_id,
404 }
405 }
406
407 pub fn node_count(&self) -> usize {
409 self.graph.node_count()
410 }
411
412 pub fn edge_count(&self) -> usize {
414 self.graph.edge_count()
415 }
416
417 pub fn external_count(&self) -> usize {
419 self.external_index.len()
420 }
421
422 pub fn from_extractions<F>(
432 extractions: &[F],
433 root: &std::path::Path,
434 snapshot_id: crate::model::SnapshotId,
435 diagnostics: &mut Vec<crate::error::Diagnostic>,
436 ) -> (CodeGraph, crate::graph::SccAnalysis)
437 where
438 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
439 {
440 let mut builder = Self::new(snapshot_id);
441
442 for file in extractions {
444 let file = file.borrow();
445 builder.add_file(file.path.clone(), file.lang);
446 }
447
448 for file in extractions {
450 let file = file.borrow();
451 for symbol in &file.symbols {
452 if let Err(e) = builder.add_symbol(symbol) {
453 diagnostics.push(crate::error::Diagnostic {
454 path: file.path.clone(),
455 severity: crate::error::Severity::Warning,
456 message: format!("failed to add symbol to graph: {e}"),
457 source_range: None,
458 });
459 }
460 }
461 }
462
463 #[cfg(feature = "dataflow")]
465 {
466 for file in extractions {
467 let file = file.borrow();
468 for data_node in &file.data_nodes {
469 builder.add_data_node(data_node);
470 }
471 }
472 for file in extractions {
473 let file = file.borrow();
474 for flow_edge in &file.flow_edges {
475 builder.add_flow_edge(
476 flow_edge.source,
477 flow_edge.target,
478 flow_edge.kind,
479 flow_edge.confidence,
480 );
481 }
482 }
483 }
484
485 let path_to_file_id: HashMap<std::path::PathBuf, crate::model::FileId> = extractions
487 .iter()
488 .filter_map(|f| {
489 let f = f.borrow();
490 builder
491 .file_id_for_path(&f.path)
492 .map(|fid| (f.path.clone(), fid))
493 })
494 .collect();
495
496 let mut resolvers = HashMap::new();
498 for lang in crate::language::LangId::all() {
499 resolvers.insert(lang, crate::language::import_resolver::make_resolver(lang));
500 }
501
502 for file in extractions {
503 let file = file.borrow();
504 let Some(&source_fid) = path_to_file_id.get(&file.path) else {
505 continue;
506 };
507 let source_dir = file.path.parent().unwrap_or(std::path::Path::new("."));
508 if let Some(resolver) = resolvers.get(&file.lang) {
509 for import in &file.imports {
510 if let Some(target) =
511 resolver.resolve(&import.import_specifier, source_dir, root)
512 {
513 builder.add_import(source_fid, target);
514 }
515 }
516 }
517 }
518
519 let import_adjacency = builder.import_adjacency();
521 let ctx = crate::graph::resolver::ResolutionContext::from_extractions(
522 extractions,
523 &path_to_file_id,
524 import_adjacency,
525 );
526 let scope_cache = crate::graph::resolver::FlattenedScopeCache::build(&ctx, diagnostics);
527 let ref_edges = crate::graph::resolver::resolve_all_references(
528 extractions,
529 &path_to_file_id,
530 &scope_cache,
531 diagnostics,
532 );
533 for (from, to, confidence) in ref_edges {
534 builder.add_reference(from, to, confidence);
535 }
536
537 let graph = builder.build();
539 let scc = crate::graph::SccAnalysis::analyze(&graph.graph);
540
541 (graph, scc)
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use crate::model::{LineColumn, SourceRange, SymbolKind, Visibility};
549
550 fn test_source_range() -> SourceRange {
551 SourceRange {
552 byte_start: 0,
553 byte_end: 10,
554 start: LineColumn { line: 0, column: 0 },
555 end: LineColumn {
556 line: 0,
557 column: 10,
558 },
559 }
560 }
561
562 fn test_symbol(id: u32, name: &str, _file_id: u32) -> Symbol {
563 Symbol {
564 id: SymbolId::new(id).unwrap(),
565 name: name.to_string(),
566 kind: SymbolKind::Function,
567 language: LangId::Python,
568 file_path: PathBuf::from("test.py"),
569 source_range: test_source_range(),
570 visibility: Some(Visibility::Public),
571 signature: None,
572 docstring: None,
573 is_async: false,
574 }
575 }
576
577 #[test]
578 fn builder_creates_file_nodes() {
579 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
580 let path = PathBuf::from("src/main.py");
581
582 let id1 = builder.add_file(path.clone(), LangId::Python);
583 let id2 = builder.add_file(path, LangId::Python);
584
585 assert_eq!(id1, id2, "same path should return same FileId");
586 assert_eq!(builder.node_count(), 1);
587 }
588
589 #[test]
590 fn builder_creates_symbol_with_ownership() {
591 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
592 let path = PathBuf::from("test.py");
593
594 let file_id = builder.add_file(path.clone(), LangId::Python);
595 let symbol = test_symbol(1, "hello", file_id.to_raw());
596
597 let _sym_idx = builder.add_symbol(&symbol).unwrap();
598
599 assert_eq!(builder.node_count(), 2); assert_eq!(builder.edge_count(), 1); }
602
603 #[test]
604 fn builder_deduplicates_edges() {
605 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
606 let path1 = PathBuf::from("a.py");
607 let path2 = PathBuf::from("b.py");
608
609 let file1 = builder.add_file(path1, LangId::Python);
610 let _file2 = builder.add_file(path2, LangId::Python);
611
612 builder.add_import(file1, PathBuf::from("b.py"));
614 builder.add_import(file1, PathBuf::from("b.py"));
615
616 assert_eq!(
617 builder.edge_count(),
618 1,
619 "duplicate edges should be deduplicated"
620 );
621 }
622
623 #[test]
624 fn builder_creates_external_node_for_unknown_import() {
625 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
626 let path = PathBuf::from("main.py");
627
628 let file_id = builder.add_file(path, LangId::Python);
629
630 builder.add_import(file_id, PathBuf::from("external_module.py"));
632
633 assert_eq!(
635 builder.edge_count(),
636 1,
637 "external import should create an edge"
638 );
639 assert_eq!(builder.external_count(), 1, "should have one external node");
640 }
641
642 #[test]
643 fn builder_tracks_node_mappings() {
644 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
645 let path = PathBuf::from("test.py");
646
647 let file_id = builder.add_file(path, LangId::Python);
648 let symbol = test_symbol(42, "func", file_id.to_raw());
649
650 builder.add_symbol(&symbol).unwrap();
651
652 assert!(builder.file_to_index.contains_key(&file_id));
654 assert!(
655 builder
656 .symbol_to_index
657 .contains_key(&SymbolId::new(42).unwrap())
658 );
659 }
660
661 #[test]
662 fn from_extractions_builds_graph_with_correct_node_count() {
663 use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
664 use std::path::PathBuf;
665 let sym = Symbol {
666 id: SymbolId::new(1).unwrap(),
667 name: "foo".into(),
668 kind: SymbolKind::Function,
669 language: LangId::Python,
670 file_path: PathBuf::from("/proj/a.py"),
671 source_range: SourceRange {
672 byte_start: 0,
673 byte_end: 10,
674 start: LineColumn { line: 0, column: 0 },
675 end: LineColumn {
676 line: 0,
677 column: 10,
678 },
679 },
680 visibility: None,
681 signature: None,
682 docstring: None,
683 is_async: false,
684 };
685 let extractions = vec![FileExtraction {
686 path: PathBuf::from("/proj/a.py"),
687 lang: LangId::Python,
688 symbols: vec![sym],
689 imports: vec![],
690 references: vec![],
691 diagnostics: vec![],
692 ast_node_count: 0,
693 #[cfg(feature = "metacall-deploy")]
694 call_sites: vec![],
695 #[cfg(feature = "dataflow")]
696 data_nodes: vec![],
697 #[cfg(feature = "dataflow")]
698 flow_edges: vec![],
699 }];
700 let mut diags = Vec::new();
701 let (graph, _scc) = GraphBuilder::from_extractions(
702 &extractions,
703 std::path::Path::new("/proj"),
704 SnapshotId::new(1).unwrap(),
705 &mut diags,
706 );
707 assert_eq!(graph.file_count(), 1);
708 assert_eq!(graph.symbol_count(), 1);
709 assert!(diags.is_empty());
710 }
711
712 #[test]
713 fn from_extractions_populates_scc_analysis() {
714 use crate::model::FileExtraction;
715 let extractions: Vec<FileExtraction> = vec![];
716 let mut diags = Vec::new();
717 let (graph, scc) = GraphBuilder::from_extractions(
718 &extractions,
719 std::path::Path::new("/proj"),
720 SnapshotId::new(1).unwrap(),
721 &mut diags,
722 );
723 assert_eq!(graph.node_count(), 0);
724 assert!(!scc.components.iter().any(|c| c.is_cyclic));
725 }
726
727 #[test]
728 fn from_extractions_accumulates_diagnostics_on_symbol_error() {
729 use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
730 use std::path::PathBuf;
731 let sym = Symbol {
732 id: SymbolId::new(99).unwrap(),
733 name: "orphan".into(),
734 kind: SymbolKind::Function,
735 language: LangId::Python,
736 file_path: PathBuf::from("/proj/missing.py"),
737 source_range: SourceRange {
738 byte_start: 0,
739 byte_end: 5,
740 start: LineColumn { line: 0, column: 0 },
741 end: LineColumn { line: 0, column: 5 },
742 },
743 visibility: None,
744 signature: None,
745 docstring: None,
746 is_async: false,
747 };
748 let extractions = vec![FileExtraction {
749 path: PathBuf::from("/proj/a.py"),
750 lang: LangId::Python,
751 symbols: vec![sym],
752 imports: vec![],
753 references: vec![],
754 diagnostics: vec![],
755 ast_node_count: 0,
756 #[cfg(feature = "metacall-deploy")]
757 call_sites: vec![],
758 #[cfg(feature = "dataflow")]
759 data_nodes: vec![],
760 #[cfg(feature = "dataflow")]
761 flow_edges: vec![],
762 }];
763 let mut diags = Vec::new();
764 let (_graph, _scc) = GraphBuilder::from_extractions(
765 &extractions,
766 std::path::Path::new("/proj"),
767 SnapshotId::new(1).unwrap(),
768 &mut diags,
769 );
770 assert!(!diags.is_empty(), "expected diagnostic for orphan symbol");
771 }
772
773 #[test]
774 fn from_extractions_resolves_cross_file_imports() {
775 use crate::model::{FileExtraction, LineColumn, SourceRange, UnresolvedImport};
776 use std::path::PathBuf;
777 let extractions = vec![
778 FileExtraction {
779 path: PathBuf::from("/proj/a.py"),
780 lang: LangId::Python,
781 symbols: vec![],
782 imports: vec![UnresolvedImport {
783 import_specifier: "b".into(),
784 alias: None,
785 symbol: None,
786 star: false,
787 range: SourceRange {
788 byte_start: 0,
789 byte_end: 1,
790 start: LineColumn { line: 0, column: 0 },
791 end: LineColumn { line: 0, column: 1 },
792 },
793 }],
794 references: vec![],
795 diagnostics: vec![],
796 ast_node_count: 0,
797 #[cfg(feature = "metacall-deploy")]
798 call_sites: vec![],
799 #[cfg(feature = "dataflow")]
800 data_nodes: vec![],
801 #[cfg(feature = "dataflow")]
802 flow_edges: vec![],
803 },
804 FileExtraction {
805 path: PathBuf::from("/proj/b.py"),
806 lang: LangId::Python,
807 symbols: vec![],
808 imports: vec![],
809 references: vec![],
810 diagnostics: vec![],
811 ast_node_count: 0,
812 #[cfg(feature = "metacall-deploy")]
813 call_sites: vec![],
814 #[cfg(feature = "dataflow")]
815 data_nodes: vec![],
816 #[cfg(feature = "dataflow")]
817 flow_edges: vec![],
818 },
819 ];
820 let mut diags = Vec::new();
821 let (graph, _scc) = GraphBuilder::from_extractions(
822 &extractions,
823 std::path::Path::new("/proj"),
824 SnapshotId::new(1).unwrap(),
825 &mut diags,
826 );
827 assert_eq!(graph.file_count(), 2);
828 let import_edges: Vec<_> = graph
829 .edges_of_kind(crate::graph::EdgeKind::Import)
830 .collect();
831 assert_eq!(
832 import_edges.len(),
833 1,
834 "expected import edge from a.py to b.py"
835 );
836 }
837
838 #[cfg(feature = "dataflow")]
839 #[test]
840 fn add_data_node_creates_ownership_edge_to_symbol() {
841 use crate::model::{DataNode, DataNodeId, DataScope};
842 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
843 let file_path = PathBuf::from("test.rs");
844 let _file_id = builder.add_file(file_path.clone(), LangId::Rust);
845 let sym = Symbol {
846 id: SymbolId::new(1).unwrap(),
847 name: "my_fn".into(),
848 kind: SymbolKind::Function,
849 language: LangId::Rust,
850 file_path,
851 source_range: test_source_range(),
852 visibility: Some(Visibility::Public),
853 signature: None,
854 docstring: None,
855 is_async: false,
856 };
857 let sym_idx = builder.add_symbol(&sym).unwrap();
858
859 let data = DataNode {
860 id: DataNodeId::new(1).unwrap(),
861 symbol_id: Some(SymbolId::new(1).unwrap()),
862 name: Some("x".into()),
863 scope: DataScope::Local,
864 type_hint: Some("i32".into()),
865 source_range: test_source_range(),
866 };
867 builder.add_data_node(&data);
868
869 let graph = builder.build();
870 let ownership_edges: Vec<_> = graph.edges_of_kind(EdgeKind::Ownership).collect();
871 assert_eq!(
872 ownership_edges.len(),
873 2,
874 "expected file→symbol and symbol→data ownership edges"
875 );
876
877 let data_ownership_edges: Vec<_> = ownership_edges
878 .into_iter()
879 .filter(|(src, _)| *src == sym_idx)
880 .collect();
881 assert!(
882 !data_ownership_edges.is_empty(),
883 "expected symbol→data ownership edge"
884 );
885 }
886}