1use std::collections::HashMap;
12use std::path::PathBuf;
13
14use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex};
15use petgraph::visit::EdgeRef;
16
17use crate::graph::CodeGraph;
18use crate::graph::edge::{CONFIDENCE_CROSS_LANGUAGE, CONFIDENCE_OWN_OR_DIRECT, 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_index: HashMap<(NodeIndex, NodeIndex, EdgeKind), EdgeIndex>,
50
51 external_index: HashMap<String, NodeIndex>,
53
54 #[cfg(feature = "dataflow")]
56 data_to_index: HashMap<DataNodeId, NodeIndex>,
57}
58
59impl GraphBuilder {
60 pub fn new(snapshot_id: SnapshotId) -> Self {
62 Self {
63 graph: DiGraph::new(),
64 file_to_index: HashMap::new(),
65 symbol_to_index: HashMap::new(),
66 path_to_file: HashMap::new(),
67 file_id_gen: IdGenerator::new(),
68 snapshot_id,
69 edge_index: HashMap::new(),
70 external_index: HashMap::new(),
71 #[cfg(feature = "dataflow")]
72 data_to_index: HashMap::new(),
73 }
74 }
75
76 pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
80 if let Some(&existing_id) = self.path_to_file.get(&path) {
82 return existing_id;
83 }
84
85 let id = self.file_id_gen.next();
86
87 let node = FileNode {
88 id,
89 path: path.clone(),
90 language,
91 snapshot_id: self.snapshot_id,
92 };
93
94 let idx = self.graph.add_node(NodeData::File(node));
95 self.file_to_index.insert(id, idx);
96 self.path_to_file.insert(path, id);
97
98 id
99 }
100
101 pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
108 if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
110 return Ok(existing);
111 }
112
113 let file_id = *self
115 .path_to_file
116 .get(&symbol.file_path)
117 .ok_or_else(|| crate::Error::Graph("file must be added before its symbols".into()))?;
118
119 let file_idx = *self
120 .file_to_index
121 .get(&file_id)
122 .ok_or_else(|| crate::Error::Graph("file index must exist".into()))?;
123
124 let node = SymbolNode {
125 id: symbol.id,
126 name: symbol.name.clone(),
127 kind: symbol.kind,
128 file_id,
129 visibility: symbol.visibility,
130 source_range: symbol.source_range.clone(),
131 };
132
133 let sym_idx = self.graph.add_node(NodeData::Symbol(node));
134 self.symbol_to_index.insert(symbol.id, sym_idx);
135
136 self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
138
139 Ok(sym_idx)
140 }
141
142 #[cfg(feature = "dataflow")]
151 pub fn add_data_node(&mut self, data_node: &crate::model::DataNode) -> NodeIndex {
152 if let Some(&existing) = self.data_to_index.get(&data_node.id) {
153 return existing;
154 }
155 let node = DataGraphNode {
156 id: data_node.id,
157 symbol_id: data_node.symbol_id,
158 name: data_node.name.clone(),
159 scope: data_node.scope,
160 type_hint: data_node.type_hint.clone(),
161 source_range: data_node.source_range.clone(),
162 };
163 let idx = self.graph.add_node(NodeData::Data(node));
164 self.data_to_index.insert(data_node.id, idx);
165
166 if let Some(symbol_id) = data_node.symbol_id
167 && let Some(&sym_idx) = self.symbol_to_index.get(&symbol_id)
168 {
169 self.add_edge_internal(sym_idx, idx, EdgeKind::Ownership, 1.0);
170 }
171
172 idx
173 }
174
175 #[cfg(feature = "dataflow")]
177 pub fn add_flow_edge(
178 &mut self,
179 source: crate::model::DataNodeId,
180 target: crate::model::DataNodeId,
181 kind: crate::model::FlowKind,
182 confidence: f32,
183 ) {
184 let Some(&src_idx) = self.data_to_index.get(&source) else {
185 return;
186 };
187 let Some(&tgt_idx) = self.data_to_index.get(&target) else {
188 return;
189 };
190 self.add_edge_internal_with_flow(src_idx, tgt_idx, EdgeKind::Flow, confidence, Some(kind));
191 }
192
193 pub fn add_import(&mut self, from: FileId, to: PathBuf) {
199 let Some(&from_idx) = self.file_to_index.get(&from) else {
200 return; };
202 let Some(source_language) = self.graph[from_idx].as_file().map(|file| file.language) else {
203 return; };
205
206 if let Some(&to_id) = self.path_to_file.get(&to) {
208 if let Some(&to_idx) = self.file_to_index.get(&to_id) {
209 let target_language = self.graph[to_idx].as_file().map(|file| file.language);
210 let confidence = if target_language == Some(source_language) {
211 CONFIDENCE_OWN_OR_DIRECT
212 } else {
213 CONFIDENCE_CROSS_LANGUAGE
214 };
215 self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, confidence);
216 }
217 return;
218 }
219
220 let raw_path = to.to_string_lossy().to_string();
222 let to_idx = if let Some(&idx) = self.external_index.get(&raw_path) {
223 idx
224 } else {
225 let node = ExternalNode {
226 raw_path: raw_path.clone(),
227 language: source_language,
228 classification: None,
229 };
230 let idx = self.graph.add_node(NodeData::External(node));
231 self.external_index.insert(raw_path, idx);
232 idx
233 };
234
235 self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, CONFIDENCE_OWN_OR_DIRECT);
236 }
237
238 #[cfg(feature = "dataflow")]
240 fn add_edge_internal_with_flow(
241 &mut self,
242 source: NodeIndex,
243 target: NodeIndex,
244 kind: EdgeKind,
245 confidence: f32,
246 flow_kind: Option<crate::model::FlowKind>,
247 ) {
248 self.insert_edge(source, target, kind, confidence, flow_kind);
249 }
250
251 fn insert_edge(
254 &mut self,
255 source: NodeIndex,
256 target: NodeIndex,
257 kind: EdgeKind,
258 confidence: f32,
259 flow_kind: Option<crate::model::FlowKind>,
260 ) {
261 let confidence = confidence.clamp(0.0, 1.0);
262 let key = (source, target, kind);
263 if let Some(&edge_idx) = self.edge_index.get(&key) {
264 self.graph[edge_idx].merge_repeated(confidence, flow_kind);
265 return;
266 }
267 let mut edge_data = EdgeData::with_confidence(kind, confidence);
268 edge_data.flow_kind = flow_kind;
269 let edge_idx = self.graph.add_edge(source, target, edge_data);
270 self.edge_index.insert(key, edge_idx);
271 }
272
273 pub fn add_reference(&mut self, from: SymbolId, to: SymbolId, confidence: f32) {
279 let Some(&from_idx) = self.symbol_to_index.get(&from) else {
280 return;
281 };
282 let Some(&to_idx) = self.symbol_to_index.get(&to) else {
283 return;
284 };
285
286 self.add_edge_internal(from_idx, to_idx, EdgeKind::Reference, confidence);
287 }
288
289 fn add_edge_internal(
294 &mut self,
295 source: NodeIndex,
296 target: NodeIndex,
297 kind: EdgeKind,
298 confidence: f32,
299 ) {
300 self.insert_edge(source, target, kind, confidence, None);
301 }
302
303 pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
305 self.path_to_file.get(path).copied()
306 }
307
308 pub fn import_adjacency(&self) -> HashMap<FileId, Vec<FileId>> {
312 let mut adjacency: HashMap<FileId, Vec<FileId>> = HashMap::new();
313 let mut index_to_file: HashMap<NodeIndex, FileId> = HashMap::new();
314 for (&file_id, &idx) in &self.file_to_index {
315 index_to_file.insert(idx, file_id);
316 }
317 for edge in self.graph.edge_references() {
318 if edge.weight().kind == EdgeKind::Import
319 && let (Some(&from_id), Some(&to_id)) = (
320 index_to_file.get(&edge.source()),
321 index_to_file.get(&edge.target()),
322 )
323 {
324 adjacency.entry(from_id).or_default().push(to_id);
325 }
326 }
327 adjacency
328 }
329
330 pub fn build(self) -> CodeGraph {
332 CodeGraph::from_parts(
333 self.graph,
334 self.edge_index,
335 self.file_to_index,
336 self.symbol_to_index,
337 self.external_index,
338 self.snapshot_id,
339 )
340 }
341
342 pub fn node_count(&self) -> usize {
344 self.graph.node_count()
345 }
346
347 pub fn edge_count(&self) -> usize {
349 self.graph.edge_count()
350 }
351
352 pub fn external_count(&self) -> usize {
354 self.external_index.len()
355 }
356
357 pub fn from_extractions<F>(
367 extractions: &[F],
368 root: &std::path::Path,
369 snapshot_id: crate::model::SnapshotId,
370 diagnostics: &mut Vec<crate::error::Diagnostic>,
371 ) -> (CodeGraph, crate::graph::SccAnalysis)
372 where
373 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
374 {
375 let (graph, scc, _) =
376 Self::from_extractions_with_scope(extractions, root, snapshot_id, diagnostics);
377 (graph, scc)
378 }
379
380 pub fn from_extractions_with_scope<F>(
382 extractions: &[F],
383 root: &std::path::Path,
384 snapshot_id: crate::model::SnapshotId,
385 diagnostics: &mut Vec<crate::error::Diagnostic>,
386 ) -> (
387 CodeGraph,
388 crate::graph::SccAnalysis,
389 crate::graph::resolver::FlattenedScopeCache,
390 )
391 where
392 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
393 {
394 let parts = Self::from_extractions_detailed(extractions, root, snapshot_id, diagnostics);
395 (parts.graph, parts.scc, parts.scope)
396 }
397
398 pub fn from_extractions_detailed<F>(
407 extractions: &[F],
408 root: &std::path::Path,
409 snapshot_id: crate::model::SnapshotId,
410 diagnostics: &mut Vec<crate::error::Diagnostic>,
411 ) -> AnalysisParts
412 where
413 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
414 {
415 let mut builder = Self::new(snapshot_id);
416
417 register_files(&mut builder, extractions);
418 register_symbols(&mut builder, extractions, diagnostics);
419 #[cfg(feature = "dataflow")]
420 register_dataflow(&mut builder, extractions);
421
422 let path_to_file_id = path_to_file_id(&builder, extractions);
423 resolve_imports(
424 &mut builder,
425 extractions,
426 root,
427 &path_to_file_id,
428 diagnostics,
429 );
430 let (scope_cache, references) =
431 resolve_references(&mut builder, extractions, &path_to_file_id, diagnostics);
432
433 let graph = builder.build();
434 #[cfg(feature = "metacall-deploy")]
435 let mut graph = graph;
436 #[cfg(feature = "metacall-deploy")]
437 inject_client_call_edges(&mut graph, extractions, root, diagnostics);
438
439 let scc = crate::graph::SccAnalysis::analyze(graph.graph());
440
441 AnalysisParts {
442 graph,
443 scc,
444 scope: scope_cache,
445 references,
446 }
447 }
448}
449
450#[derive(Debug)]
452pub struct AnalysisParts {
453 pub graph: CodeGraph,
454 pub scc: crate::graph::SccAnalysis,
455 pub scope: crate::graph::resolver::FlattenedScopeCache,
457 pub references: Vec<crate::graph::resolver::ResolvedReference>,
459}
460fn register_files<F>(builder: &mut GraphBuilder, extractions: &[F])
462where
463 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
464{
465 for file in extractions {
466 let file = file.borrow();
467 builder.add_file(file.path.clone(), file.lang);
468 }
469}
470
471fn register_symbols<F>(
476 builder: &mut GraphBuilder,
477 extractions: &[F],
478 diagnostics: &mut Vec<crate::error::Diagnostic>,
479) where
480 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
481{
482 for file in extractions {
483 let file = file.borrow();
484 for symbol in &file.symbols {
485 if let Err(error) = builder.add_symbol(symbol) {
486 diagnostics.push(crate::error::Diagnostic {
487 path: file.path.clone(),
488 severity: crate::error::Severity::Warning,
489 message: format!("failed to add symbol to graph: {error}"),
490 source_range: None,
491 });
492 }
493 }
494 }
495}
496
497#[cfg(feature = "dataflow")]
499fn register_dataflow<F>(builder: &mut GraphBuilder, extractions: &[F])
500where
501 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
502{
503 for file in extractions {
504 let file = file.borrow();
505 for data_node in &file.data_nodes {
506 builder.add_data_node(data_node);
507 }
508 }
509 for file in extractions {
510 let file = file.borrow();
511 for flow_edge in &file.flow_edges {
512 builder.add_flow_edge(
513 flow_edge.source,
514 flow_edge.target,
515 flow_edge.kind,
516 flow_edge.confidence,
517 );
518 }
519 }
520}
521
522fn path_to_file_id<F>(
524 builder: &GraphBuilder,
525 extractions: &[F],
526) -> HashMap<std::path::PathBuf, crate::model::FileId>
527where
528 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
529{
530 extractions
531 .iter()
532 .filter_map(|file| {
533 let file = file.borrow();
534 builder
535 .file_id_for_path(&file.path)
536 .map(|id| (file.path.clone(), id))
537 })
538 .collect()
539}
540
541fn resolve_imports<F>(
547 builder: &mut GraphBuilder,
548 extractions: &[F],
549 root: &std::path::Path,
550 path_to_file_id: &HashMap<std::path::PathBuf, crate::model::FileId>,
551 diagnostics: &mut Vec<crate::error::Diagnostic>,
552) where
553 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
554{
555 let mut resolvers = HashMap::new();
556 for lang in crate::language::LangId::all() {
557 resolvers.insert(lang, crate::language::import_resolver::make_resolver(lang));
558 }
559
560 for file in extractions {
561 let file = file.borrow();
562 let Some(&source_id) = path_to_file_id.get(&file.path) else {
563 continue;
564 };
565 let source_dir = file
566 .path
567 .parent()
568 .unwrap_or_else(|| std::path::Path::new("."));
569 let Some(resolver) = resolvers.get(&file.lang) else {
570 continue;
571 };
572 for import in &file.imports {
573 let specifier = import_specifier_for(file.lang, import);
574 match resolver.resolve(&specifier, source_dir, root) {
575 Some(target) => builder.add_import(source_id, target),
576 None if is_relative_specifier(&specifier) => {
577 diagnostics.push(crate::error::Diagnostic {
578 path: file.path.clone(),
579 severity: crate::error::Severity::Warning,
580 message: format!("unresolved relative import: {specifier}"),
581 source_range: Some(import.range.clone()),
582 });
583 }
584 None => builder.add_import(
585 source_id,
586 std::path::PathBuf::from(external_name(file.lang, &specifier)),
587 ),
588 }
589 }
590 }
591}
592
593fn resolve_references<F>(
596 builder: &mut GraphBuilder,
597 extractions: &[F],
598 path_to_file_id: &HashMap<std::path::PathBuf, crate::model::FileId>,
599 diagnostics: &mut Vec<crate::error::Diagnostic>,
600) -> (
601 crate::graph::resolver::FlattenedScopeCache,
602 Vec<crate::graph::resolver::ResolvedReference>,
603)
604where
605 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
606{
607 let import_adjacency = builder.import_adjacency();
608 let context = crate::graph::resolver::ResolutionContext::from_extractions(
609 extractions,
610 path_to_file_id,
611 import_adjacency,
612 );
613 let scope_cache = crate::graph::resolver::FlattenedScopeCache::build(&context, diagnostics);
614 let references = crate::graph::resolver::resolve_references_detailed(
615 extractions,
616 path_to_file_id,
617 &scope_cache,
618 diagnostics,
619 );
620 for (from, to, confidence) in crate::graph::resolver::reference_edges(&references) {
621 builder.add_reference(from, to, confidence);
622 }
623 (scope_cache, references)
624}
625
626#[cfg(feature = "metacall-deploy")]
631fn inject_client_call_edges<F>(
632 graph: &mut CodeGraph,
633 extractions: &[F],
634 root: &std::path::Path,
635 diagnostics: &mut Vec<crate::error::Diagnostic>,
636) where
637 F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
638{
639 let call_sites: Vec<crate::deploy::scanner::CallSite> = extractions
640 .iter()
641 .flat_map(|file| file.borrow().call_sites.iter().cloned())
642 .collect();
643 if call_sites.is_empty() {
644 return;
645 }
646 let projections = crate::deploy::client_call::resolve_client_call_projections(
647 graph,
648 extractions,
649 &call_sites,
650 root,
651 );
652 diagnostics.extend(projections.diagnostics);
653 for (from, to, confidence) in projections.file_edges {
654 graph.add_edge_normalized(from, to, EdgeKind::Reference, confidence);
655 }
656 for (from, to, confidence) in projections.symbol_edges {
657 if let (Some(from_idx), Some(to_idx)) =
658 (graph.symbol_node_index(from), graph.symbol_node_index(to))
659 {
660 graph.add_edge_normalized(from_idx, to_idx, EdgeKind::Reference, confidence);
661 }
662 }
663}
664
665fn import_specifier_for<'a>(
670 lang: crate::language::LangId,
671 import: &'a crate::model::UnresolvedImport,
672) -> std::borrow::Cow<'a, str> {
673 if lang == crate::language::LangId::Python {
674 crate::language::python::normalize_relative_specifier(
675 &import.import_specifier,
676 import.symbol.as_deref(),
677 import.star,
678 )
679 } else {
680 std::borrow::Cow::Borrowed(import.import_specifier.as_str())
681 }
682}
683
684fn is_relative_specifier(specifier: &str) -> bool {
686 let stripped = specifier.trim_matches(|c| c == '\'' || c == '"');
687 stripped.starts_with('.') || stripped.starts_with('/')
688}
689
690fn external_name(lang: crate::language::LangId, specifier: &str) -> String {
692 use crate::language::{LangId, import_resolver};
693 let name = match lang {
694 LangId::C | LangId::Cpp => import_resolver::strip_c_family_quotes(specifier),
695 _ => import_resolver::strip_import_quotes(specifier),
696 };
697 name.to_string()
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use crate::model::{LineColumn, SourceRange, SymbolKind, Visibility};
704
705 fn test_source_range() -> SourceRange {
706 SourceRange {
707 byte_start: 0,
708 byte_end: 10,
709 start: LineColumn { line: 0, column: 0 },
710 end: LineColumn {
711 line: 0,
712 column: 10,
713 },
714 }
715 }
716
717 fn test_symbol(id: u32, name: &str, _file_id: u32) -> Symbol {
718 Symbol {
719 id: SymbolId::new(id).unwrap(),
720 name: name.to_string(),
721 kind: SymbolKind::Function,
722 language: LangId::Python,
723 file_path: PathBuf::from("test.py"),
724 source_range: test_source_range(),
725 name_range: None,
726 visibility: Some(Visibility::Public),
727 signature: None,
728 docstring: None,
729 is_async: false,
730 }
731 }
732
733 #[test]
734 fn builder_creates_file_nodes() {
735 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
736 let path = PathBuf::from("src/main.py");
737
738 let id1 = builder.add_file(path.clone(), LangId::Python);
739 let id2 = builder.add_file(path, LangId::Python);
740
741 assert_eq!(id1, id2, "same path should return same FileId");
742 assert_eq!(builder.node_count(), 1);
743 }
744
745 #[test]
746 fn builder_creates_symbol_with_ownership() {
747 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
748 let path = PathBuf::from("test.py");
749
750 let file_id = builder.add_file(path.clone(), LangId::Python);
751 let symbol = test_symbol(1, "hello", file_id.to_raw());
752
753 let _sym_idx = builder.add_symbol(&symbol).unwrap();
754
755 assert_eq!(builder.node_count(), 2); assert_eq!(builder.edge_count(), 1); }
758
759 #[test]
760 fn builder_deduplicates_edges() {
761 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
762 let path1 = PathBuf::from("a.py");
763 let path2 = PathBuf::from("b.py");
764
765 let file1 = builder.add_file(path1, LangId::Python);
766 let _file2 = builder.add_file(path2, LangId::Python);
767
768 builder.add_import(file1, PathBuf::from("b.py"));
770 builder.add_import(file1, PathBuf::from("b.py"));
771
772 assert_eq!(
773 builder.edge_count(),
774 1,
775 "duplicate edges should be deduplicated"
776 );
777 }
778
779 #[test]
780 fn builder_reference_max_merges_confidence() {
781 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
782 builder.add_file(PathBuf::from("test.py"), LangId::Python);
783 let low = test_symbol(10, "low_fn", 0);
784 let high = test_symbol(11, "high_fn", 0);
785 builder.add_symbol(&low).unwrap();
786 builder.add_symbol(&high).unwrap();
787
788 builder.add_reference(low.id, high.id, 0.5);
789 builder.add_reference(low.id, high.id, 0.9);
790 assert_eq!(builder.edge_count(), 3);
791
792 let graph = builder.build();
793 assert_eq!(graph.edge_count(), 3);
794 let refs: Vec<_> = graph.edges_of_kind(EdgeKind::Reference).collect();
795 assert_eq!(refs.len(), 1);
796 let (src, dst) = refs[0];
797 let edge = graph.graph().edges_connecting(src, dst).next().unwrap();
798 assert_eq!(edge.weight().confidence, 0.9);
799 }
800
801 #[test]
802 fn builder_creates_external_node_for_unknown_import() {
803 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
804 let path = PathBuf::from("main.py");
805
806 let file_id = builder.add_file(path, LangId::Python);
807
808 builder.add_import(file_id, PathBuf::from("external_module.py"));
810
811 assert_eq!(
813 builder.edge_count(),
814 1,
815 "external import should create an edge"
816 );
817 assert_eq!(builder.external_count(), 1, "should have one external node");
818 }
819
820 #[test]
821 fn builder_tracks_node_mappings() {
822 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
823 let path = PathBuf::from("test.py");
824
825 let file_id = builder.add_file(path, LangId::Python);
826 let symbol = test_symbol(42, "func", file_id.to_raw());
827
828 builder.add_symbol(&symbol).unwrap();
829
830 assert!(builder.file_to_index.contains_key(&file_id));
832 assert!(
833 builder
834 .symbol_to_index
835 .contains_key(&SymbolId::new(42).unwrap())
836 );
837 }
838
839 #[test]
840 fn from_extractions_builds_graph_with_correct_node_count() {
841 use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
842 use std::path::PathBuf;
843 let sym = Symbol {
844 id: SymbolId::new(1).unwrap(),
845 name: "foo".into(),
846 kind: SymbolKind::Function,
847 language: LangId::Python,
848 file_path: PathBuf::from("/proj/a.py"),
849 source_range: SourceRange {
850 byte_start: 0,
851 byte_end: 10,
852 start: LineColumn { line: 0, column: 0 },
853 end: LineColumn {
854 line: 0,
855 column: 10,
856 },
857 },
858 name_range: None,
859 visibility: None,
860 signature: None,
861 docstring: None,
862 is_async: false,
863 };
864 let mut base = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
865 base.symbols = vec![sym];
866 let extractions = vec![base];
867 let mut diags = Vec::new();
868 let (graph, _scc) = GraphBuilder::from_extractions(
869 &extractions,
870 std::path::Path::new("/proj"),
871 SnapshotId::new(1).unwrap(),
872 &mut diags,
873 );
874 assert_eq!(graph.file_count(), 1);
875 assert_eq!(graph.symbol_count(), 1);
876 assert!(diags.is_empty());
877 }
878
879 #[test]
880 fn from_extractions_populates_scc_analysis() {
881 use crate::model::FileExtraction;
882 let extractions: Vec<FileExtraction> = vec![];
883 let mut diags = Vec::new();
884 let (graph, scc) = GraphBuilder::from_extractions(
885 &extractions,
886 std::path::Path::new("/proj"),
887 SnapshotId::new(1).unwrap(),
888 &mut diags,
889 );
890 assert_eq!(graph.node_count(), 0);
891 assert!(!scc.components.iter().any(|c| c.is_cyclic));
892 }
893
894 #[test]
895 fn from_extractions_accumulates_diagnostics_on_symbol_error() {
896 use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
897 use std::path::PathBuf;
898 let sym = Symbol {
899 id: SymbolId::new(99).unwrap(),
900 name: "orphan".into(),
901 kind: SymbolKind::Function,
902 language: LangId::Python,
903 file_path: PathBuf::from("/proj/missing.py"),
904 source_range: SourceRange {
905 byte_start: 0,
906 byte_end: 5,
907 start: LineColumn { line: 0, column: 0 },
908 end: LineColumn { line: 0, column: 5 },
909 },
910 name_range: None,
911 visibility: None,
912 signature: None,
913 docstring: None,
914 is_async: false,
915 };
916 let mut base = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
917 base.symbols = vec![sym];
918 let extractions = vec![base];
919 let mut diags = Vec::new();
920 let (_graph, _scc) = GraphBuilder::from_extractions(
921 &extractions,
922 std::path::Path::new("/proj"),
923 SnapshotId::new(1).unwrap(),
924 &mut diags,
925 );
926 assert!(!diags.is_empty(), "expected diagnostic for orphan symbol");
927 }
928
929 #[test]
930 fn from_extractions_resolves_cross_file_imports() {
931 use crate::model::{FileExtraction, LineColumn, SourceRange, UnresolvedImport};
932 use std::path::PathBuf;
933 let mut first = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
934 first.imports = vec![UnresolvedImport {
935 import_specifier: "b".into(),
936 alias: None,
937 symbol: None,
938 star: false,
939 range: SourceRange {
940 byte_start: 0,
941 byte_end: 1,
942 start: LineColumn { line: 0, column: 0 },
943 end: LineColumn { line: 0, column: 1 },
944 },
945 }];
946 let second = FileExtraction::empty(PathBuf::from("/proj/b.py"), LangId::Python);
947 let extractions = vec![first, second];
948 let mut diags = Vec::new();
949 let (graph, _scc) = GraphBuilder::from_extractions(
950 &extractions,
951 std::path::Path::new("/proj"),
952 SnapshotId::new(1).unwrap(),
953 &mut diags,
954 );
955 assert_eq!(graph.file_count(), 2);
956 let import_edges: Vec<_> = graph
957 .edges_of_kind(crate::graph::EdgeKind::Import)
958 .collect();
959 assert_eq!(
960 import_edges.len(),
961 1,
962 "expected import edge from a.py to b.py"
963 );
964 }
965
966 #[cfg(feature = "dataflow")]
967 #[test]
968 fn add_data_node_creates_ownership_edge_to_symbol() {
969 use crate::model::{DataNode, DataNodeId, DataScope};
970 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
971 let file_path = PathBuf::from("test.rs");
972 let _file_id = builder.add_file(file_path.clone(), LangId::Rust);
973 let sym = Symbol {
974 id: SymbolId::new(1).unwrap(),
975 name: "my_fn".into(),
976 kind: SymbolKind::Function,
977 language: LangId::Rust,
978 file_path,
979 source_range: test_source_range(),
980 name_range: None,
981 visibility: Some(Visibility::Public),
982 signature: None,
983 docstring: None,
984 is_async: false,
985 };
986 let sym_idx = builder.add_symbol(&sym).unwrap();
987
988 let data = DataNode {
989 id: DataNodeId::new(1).unwrap(),
990 symbol_id: Some(SymbolId::new(1).unwrap()),
991 name: Some("x".into()),
992 scope: DataScope::Local,
993 type_hint: Some("i32".into()),
994 source_range: test_source_range(),
995 };
996 builder.add_data_node(&data);
997
998 let graph = builder.build();
999 let ownership_edges: Vec<_> = graph.edges_of_kind(EdgeKind::Ownership).collect();
1000 assert_eq!(
1001 ownership_edges.len(),
1002 2,
1003 "expected file→symbol and symbol→data ownership edges"
1004 );
1005
1006 let data_ownership_edges: Vec<_> = ownership_edges
1007 .into_iter()
1008 .filter(|(src, _)| *src == sym_idx)
1009 .collect();
1010 assert!(
1011 !data_ownership_edges.is_empty(),
1012 "expected symbol→data ownership edge"
1013 );
1014 }
1015 #[test]
1017 fn registration_stage_adds_files_and_symbol_ownership() {
1018 use crate::model::FileExtraction;
1019 let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
1020 file.symbols = vec![symbol_for(1, "alpha", std::path::Path::new("/proj/a.py"))];
1021 let extractions = vec![file];
1022
1023 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1024 let mut diagnostics = Vec::new();
1025 register_files(&mut builder, &extractions);
1026 register_symbols(&mut builder, &extractions, &mut diagnostics);
1027
1028 assert!(diagnostics.is_empty());
1029 assert_eq!(builder.node_count(), 2, "one file node and one symbol node");
1030 assert_eq!(builder.edge_count(), 1, "the symbol owns its file");
1031 }
1032
1033 #[cfg(feature = "dataflow")]
1035 #[test]
1036 fn dataflow_stage_registers_nodes_and_flow_edges() {
1037 use crate::model::{DataNodeId, FileExtraction, FlowEdge, FlowKind};
1038 let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
1039 file.symbols = vec![symbol_for(1, "alpha", std::path::Path::new("/proj/a.py"))];
1040 file.data_nodes = vec![data_node(1, "x"), data_node(2, "y")];
1041 file.flow_edges = vec![FlowEdge {
1042 source: DataNodeId::new(1).unwrap(),
1043 target: DataNodeId::new(2).unwrap(),
1044 kind: FlowKind::Argument,
1045 confidence: 0.8,
1046 }];
1047 let extractions = vec![file];
1048
1049 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1050 register_files(&mut builder, &extractions);
1051 register_symbols(&mut builder, &extractions, &mut Vec::new());
1052 register_dataflow(&mut builder, &extractions);
1053
1054 assert_eq!(builder.node_count(), 4, "file, symbol and two data nodes");
1055 let graph = builder.build();
1056 assert_eq!(graph.edges_of_kind(EdgeKind::Flow).count(), 1);
1057 }
1058
1059 #[test]
1062 fn import_stage_resolves_project_and_external_targets() {
1063 use crate::model::FileExtraction;
1064 let project = tempfile::tempdir().unwrap();
1066 let root = project.path();
1067 std::fs::write(root.join("a.py"), "import b\nimport requests\n").unwrap();
1068 std::fs::write(root.join("b.py"), "def beta(): pass\n").unwrap();
1069
1070 let mut first = FileExtraction::empty(root.join("a.py"), LangId::Python);
1071 first.imports = vec![import("b"), import("requests")];
1072 let second = FileExtraction::empty(root.join("b.py"), LangId::Python);
1073 let extractions = vec![first, second];
1074
1075 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1076 register_files(&mut builder, &extractions);
1077 let file_ids = path_to_file_id(&builder, &extractions);
1078 let mut diagnostics = Vec::new();
1079 resolve_imports(
1080 &mut builder,
1081 &extractions,
1082 root,
1083 &file_ids,
1084 &mut diagnostics,
1085 );
1086
1087 assert!(diagnostics.is_empty(), "both imports resolve");
1088 let graph = builder.build();
1089 assert_eq!(graph.edges_of_kind(EdgeKind::Import).count(), 2);
1090 assert_eq!(graph.external_count(), 1, "requests is an external node");
1091 }
1092
1093 #[test]
1096 fn reference_stage_returns_a_usable_scope_cache() {
1097 use crate::model::{FileExtraction, UnresolvedReference};
1098 let project = tempfile::tempdir().unwrap();
1099 let root = project.path();
1100 let first_path = root.join("a.py");
1101 let second_path = root.join("b.py");
1102 std::fs::write(&first_path, "def alpha(): pass\n").unwrap();
1103 std::fs::write(&second_path, "import a\ndef beta(): return alpha()\n").unwrap();
1104
1105 let mut first = FileExtraction::empty(first_path.clone(), LangId::Python);
1106 first.symbols = vec![symbol_for(1, "alpha", &first_path)];
1107 let mut second = FileExtraction::empty(second_path.clone(), LangId::Python);
1108 second.symbols = vec![symbol_for(2, "beta", &second_path)];
1109 second.imports = vec![import("a")];
1110 second.references = vec![UnresolvedReference {
1111 name: "alpha".to_string(),
1112 range: test_source_range(),
1113 }];
1114 let extractions = vec![first, second];
1115
1116 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1117 register_files(&mut builder, &extractions);
1118 register_symbols(&mut builder, &extractions, &mut Vec::new());
1119 let file_ids = path_to_file_id(&builder, &extractions);
1120 let mut diagnostics = Vec::new();
1121 resolve_imports(
1122 &mut builder,
1123 &extractions,
1124 root,
1125 &file_ids,
1126 &mut diagnostics,
1127 );
1128 let (scope, _references) =
1129 resolve_references(&mut builder, &extractions, &file_ids, &mut diagnostics);
1130
1131 assert_eq!(scope.iter_scopes().count(), 2);
1132 let graph = builder.build();
1133 assert_eq!(
1134 graph.edges_of_kind(EdgeKind::Reference).count(),
1135 1,
1136 "beta references alpha"
1137 );
1138 }
1139
1140 #[cfg(feature = "metacall-deploy")]
1143 #[test]
1144 fn client_call_stage_projects_the_call_onto_its_symbol() {
1145 use crate::deploy::scanner::CallSite;
1146 use crate::model::FileExtraction;
1147 let mut caller =
1148 FileExtraction::empty(PathBuf::from("/proj/orchestrator.py"), LangId::Python);
1149 caller.symbols = vec![symbol_for(
1150 1,
1151 "compute",
1152 std::path::Path::new("/proj/orchestrator.py"),
1153 )];
1154 caller.call_sites = vec![CallSite::call(
1155 PathBuf::from("/proj/orchestrator.py"),
1156 LangId::Python,
1157 "multiply".to_string(),
1158 false,
1159 Some(test_source_range()),
1160 1.0,
1161 )];
1162 let mut callee = FileExtraction::empty(PathBuf::from("/proj/math.js"), LangId::JavaScript);
1163 callee.symbols = vec![symbol_for(
1164 2,
1165 "multiply",
1166 std::path::Path::new("/proj/math.js"),
1167 )];
1168 let extractions = vec![caller, callee];
1169
1170 let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
1171 register_files(&mut builder, &extractions);
1172 register_symbols(&mut builder, &extractions, &mut Vec::new());
1173 let mut graph = builder.build();
1174 let mut diagnostics = Vec::new();
1175 inject_client_call_edges(
1176 &mut graph,
1177 &extractions,
1178 std::path::Path::new("/proj"),
1179 &mut diagnostics,
1180 );
1181
1182 assert!(
1183 graph.reference_edges().count() >= 1,
1184 "the invocation must project onto its symbol"
1185 );
1186 }
1187
1188 fn symbol_for(id: u32, name: &str, file_path: &std::path::Path) -> Symbol {
1189 Symbol {
1190 id: SymbolId::new(id).unwrap(),
1191 name: name.to_string(),
1192 kind: SymbolKind::Function,
1193 language: LangId::Python,
1194 file_path: file_path.to_path_buf(),
1195 source_range: test_source_range(),
1196 name_range: None,
1197 visibility: Some(Visibility::Public),
1198 signature: None,
1199 docstring: None,
1200 is_async: false,
1201 }
1202 }
1203
1204 fn import(specifier: &str) -> crate::model::UnresolvedImport {
1205 crate::model::UnresolvedImport {
1206 import_specifier: specifier.to_string(),
1207 alias: None,
1208 symbol: None,
1209 star: false,
1210 range: test_source_range(),
1211 }
1212 }
1213
1214 #[cfg(feature = "dataflow")]
1215 fn data_node(id: u32, name: &str) -> crate::model::DataNode {
1216 use crate::model::{DataNodeId, DataScope};
1217 crate::model::DataNode {
1218 id: DataNodeId::new(id).unwrap(),
1219 symbol_id: None,
1220 name: Some(name.to_string()),
1221 scope: DataScope::Local,
1222 type_hint: None,
1223 source_range: test_source_range(),
1224 }
1225 }
1226}