1use std::collections::HashMap;
11use std::path::PathBuf;
12
13use petgraph::graph::{DiGraph, NodeIndex};
14use petgraph::visit::EdgeRef;
15
16use crate::graph::CodeGraph;
17use crate::graph::edge::{EdgeData, EdgeKind};
18use crate::graph::node::{ExternalNode, FileNode, NodeData, SymbolNode};
19use crate::language::LangId;
20use crate::model::{FileId, IdGenerator, SnapshotId, Symbol, SymbolId};
21
22#[derive(Debug)]
24pub struct GraphBuilder {
25 graph: DiGraph<NodeData, EdgeData>,
27
28 file_to_index: HashMap<FileId, NodeIndex>,
30
31 symbol_to_index: HashMap<SymbolId, NodeIndex>,
33
34 path_to_file: HashMap<PathBuf, FileId>,
36
37 file_id_gen: IdGenerator<FileId>,
39
40 snapshot_id: SnapshotId,
42
43 edge_normalizer: EdgeNormalizer,
45
46 external_index: HashMap<String, NodeIndex>,
48}
49
50#[derive(Debug, Default)]
52struct EdgeNormalizer {
53 seen: std::collections::HashSet<(NodeIndex, NodeIndex, EdgeKind)>,
54}
55
56impl EdgeNormalizer {
57 fn is_new(&mut self, source: NodeIndex, target: NodeIndex, kind: EdgeKind) -> bool {
58 self.seen.insert((source, target, kind))
59 }
60}
61
62impl GraphBuilder {
63 pub fn new(snapshot_id: SnapshotId) -> Self {
65 Self {
66 graph: DiGraph::new(),
67 file_to_index: HashMap::new(),
68 symbol_to_index: HashMap::new(),
69 path_to_file: HashMap::new(),
70 file_id_gen: IdGenerator::new(),
71 snapshot_id,
72 edge_normalizer: EdgeNormalizer::default(),
73 external_index: HashMap::new(),
74 }
75 }
76
77 pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
81 if let Some(&existing_id) = self.path_to_file.get(&path) {
83 return existing_id;
84 }
85
86 let id = self.file_id_gen.next();
87
88 let node = FileNode {
89 id,
90 path: path.clone(),
91 language,
92 snapshot_id: self.snapshot_id,
93 };
94
95 let idx = self.graph.add_node(NodeData::File(node));
96
97 self.file_to_index.insert(id, idx);
98 self.path_to_file.insert(path, id);
99
100 id
101 }
102
103 pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
110 if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
112 return Ok(existing);
113 }
114
115 let file_id = *self
117 .path_to_file
118 .get(&symbol.file_path)
119 .ok_or_else(|| crate::Error::Graph("file must be added before its symbols".into()))?;
120
121 let file_idx = *self
122 .file_to_index
123 .get(&file_id)
124 .ok_or_else(|| crate::Error::Graph("file index must exist".into()))?;
125
126 let node = SymbolNode {
127 id: symbol.id,
128 name: symbol.name.clone(),
129 kind: symbol.kind,
130 file_id,
131 visibility: symbol.visibility,
132 source_range: symbol.source_range.clone(),
133 };
134
135 let sym_idx = self.graph.add_node(NodeData::Symbol(node));
136 self.symbol_to_index.insert(symbol.id, sym_idx);
137
138 self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
140
141 Ok(sym_idx)
142 }
143
144 pub fn add_import(&mut self, from: FileId, to: PathBuf) {
150 let Some(&from_idx) = self.file_to_index.get(&from) else {
151 return; };
153
154 if let Some(&to_id) = self.path_to_file.get(&to) {
156 if let Some(&to_idx) = self.file_to_index.get(&to_id) {
157 self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
158 }
159 return;
160 }
161
162 let raw_path = to.to_string_lossy().to_string();
164 let to_idx = if let Some(&idx) = self.external_index.get(&raw_path) {
165 idx
166 } else {
167 let language = self
169 .path_to_file
170 .iter()
171 .find(|(_, id)| **id == from)
172 .map(|(p, _)| {
173 crate::input::detect_language(p).unwrap_or(crate::language::LangId::Python)
174 })
175 .unwrap_or(crate::language::LangId::Python);
176
177 let node = ExternalNode {
178 raw_path: raw_path.clone(),
179 language,
180 classification: None,
181 };
182 let idx = self.graph.add_node(NodeData::External(node));
183 self.external_index.insert(raw_path, idx);
184 idx
185 };
186
187 self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
188 }
189
190 pub fn add_metacall_load(
192 &mut self,
193 from_path: PathBuf,
194 target_lang: LangId,
195 scripts: &[String],
196 confidence: f64,
197 root: &std::path::Path,
198 ) {
199 let Some(&from_fid) = self.path_to_file.get(&from_path) else {
200 return;
201 };
202 let Some(&from_idx) = self.file_to_index.get(&from_fid) else {
203 return;
204 };
205
206 for script in scripts {
207 let target_path = root.join(script);
208 if let Some(&to_fid) = self.path_to_file.get(&target_path) {
210 if let Some(&to_idx) = self.file_to_index.get(&to_fid) {
211 let edge_data = EdgeData {
212 kind: EdgeKind::Import,
213 confidence: confidence as f32,
214 };
215 self.graph.add_edge(from_idx, to_idx, edge_data);
216 }
217 } else {
218 let raw_path = script.clone();
220 if let Some(&to_idx) = self.external_index.get(&raw_path) {
221 let edge_data = EdgeData {
222 kind: EdgeKind::Import,
223 confidence: confidence as f32,
224 };
225 self.graph.add_edge(from_idx, to_idx, edge_data);
226 } else {
227 let node = ExternalNode {
228 raw_path: raw_path.clone(),
229 language: target_lang,
230 classification: None,
231 };
232 let idx = self.graph.add_node(NodeData::External(node));
233 self.external_index.insert(raw_path, idx);
234
235 let edge_data = EdgeData {
236 kind: EdgeKind::Import,
237 confidence: confidence as f32,
238 };
239 self.graph.add_edge(from_idx, idx, edge_data);
240 }
241 }
242 }
243 }
244
245 pub fn add_reference(&mut self, from: SymbolId, to: SymbolId, confidence: f32) {
251 let Some(&from_idx) = self.symbol_to_index.get(&from) else {
252 return;
253 };
254 let Some(&to_idx) = self.symbol_to_index.get(&to) else {
255 return;
256 };
257
258 self.add_edge_internal(from_idx, to_idx, EdgeKind::Reference, confidence);
259 }
260
261 fn add_edge_internal(
266 &mut self,
267 source: NodeIndex,
268 target: NodeIndex,
269 kind: EdgeKind,
270 confidence: f32,
271 ) {
272 if !self.edge_normalizer.is_new(source, target, kind) {
273 if let Some(edge_idx) = self.graph.find_edge(source, target) {
275 let existing = &mut self.graph[edge_idx];
276 if existing.kind == kind {
277 existing.confidence = existing.confidence.max(confidence);
278 }
279 }
280 return;
281 }
282
283 let edge_data = EdgeData { kind, confidence };
284
285 self.graph.add_edge(source, target, edge_data);
286 }
287
288 pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
290 self.path_to_file.get(path).copied()
291 }
292
293 pub fn import_adjacency(&self) -> HashMap<FileId, Vec<FileId>> {
297 let mut adjacency: HashMap<FileId, Vec<FileId>> = HashMap::new();
298 let mut index_to_file: HashMap<NodeIndex, FileId> = HashMap::new();
299 for (&file_id, &idx) in &self.file_to_index {
300 index_to_file.insert(idx, file_id);
301 }
302 for edge in self.graph.edge_references() {
303 if edge.weight().kind == EdgeKind::Import
304 && let (Some(&from_id), Some(&to_id)) = (
305 index_to_file.get(&edge.source()),
306 index_to_file.get(&edge.target()),
307 )
308 {
309 adjacency.entry(from_id).or_default().push(to_id);
310 }
311 }
312 adjacency
313 }
314
315 pub fn build(self) -> CodeGraph {
317 CodeGraph {
318 graph: self.graph,
319 file_to_index: self.file_to_index,
320 symbol_to_index: self.symbol_to_index,
321 external_index: self.external_index,
322 snapshot_id: self.snapshot_id,
323 }
324 }
325
326 pub fn node_count(&self) -> usize {
328 self.graph.node_count()
329 }
330
331 pub fn edge_count(&self) -> usize {
333 self.graph.edge_count()
334 }
335
336 pub fn external_count(&self) -> usize {
338 self.external_index.len()
339 }
340
341 pub fn from_extractions(
351 extractions: &[crate::model::FileExtraction],
352 root: &std::path::Path,
353 snapshot_id: crate::model::SnapshotId,
354 diagnostics: &mut Vec<crate::error::Diagnostic>,
355 ) -> (CodeGraph, crate::graph::SccAnalysis) {
356 let mut builder = Self::new(snapshot_id);
357
358 for file in extractions {
360 builder.add_file(file.path.clone(), file.lang);
361 }
362
363 for file in extractions {
365 for symbol in &file.symbols {
366 if let Err(e) = builder.add_symbol(symbol) {
367 diagnostics.push(crate::error::Diagnostic {
368 path: file.path.clone(),
369 severity: crate::error::Severity::Warning,
370 message: format!("failed to add symbol to graph: {e}"),
371 source_range: None,
372 });
373 }
374 }
375 }
376
377 let path_to_file_id: HashMap<std::path::PathBuf, crate::model::FileId> = extractions
379 .iter()
380 .filter_map(|f| {
381 builder
382 .file_id_for_path(&f.path)
383 .map(|fid| (f.path.clone(), fid))
384 })
385 .collect();
386
387 let mut resolvers = HashMap::new();
389 for lang in crate::language::LangId::all() {
390 resolvers.insert(lang, crate::language::import_resolver::make_resolver(lang));
391 }
392
393 for file in extractions {
394 let Some(&source_fid) = path_to_file_id.get(&file.path) else {
395 continue;
396 };
397 let source_dir = file.path.parent().unwrap_or(std::path::Path::new("."));
398 if let Some(resolver) = resolvers.get(&file.lang) {
399 for import in &file.imports {
400 if let Some(target) =
401 resolver.resolve(&import.import_specifier, source_dir, root)
402 {
403 builder.add_import(source_fid, target);
404 }
405 }
406 }
407 }
408
409 let import_adjacency = builder.import_adjacency();
411 let ctx = crate::graph::resolver::ResolutionContext::from_extractions(
412 extractions,
413 &path_to_file_id,
414 import_adjacency,
415 );
416 let scope_cache = crate::graph::resolver::FlattenedScopeCache::build(&ctx, diagnostics);
417 let ref_edges = crate::graph::resolver::resolve_all_references(
418 extractions,
419 &path_to_file_id,
420 &scope_cache,
421 diagnostics,
422 );
423 for (from, to, confidence) in ref_edges {
424 builder.add_reference(from, to, confidence);
425 }
426
427 let graph = builder.build();
429 let scc = crate::graph::SccAnalysis::analyze(&graph.graph);
430
431 (graph, scc)
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::model::{LineColumn, SourceRange, SymbolKind, Visibility};
439
440 fn test_source_range() -> SourceRange {
441 SourceRange {
442 byte_start: 0,
443 byte_end: 10,
444 start: LineColumn { line: 0, column: 0 },
445 end: LineColumn {
446 line: 0,
447 column: 10,
448 },
449 }
450 }
451
452 fn test_symbol(id: u32, name: &str, _file_id: u32) -> Symbol {
453 Symbol {
454 id: SymbolId(id),
455 name: name.to_string(),
456 kind: SymbolKind::Function,
457 language: LangId::Python,
458 file_path: PathBuf::from("test.py"),
459 source_range: test_source_range(),
460 visibility: Some(Visibility::Public),
461 signature: None,
462 docstring: None,
463 is_async: false,
464 }
465 }
466
467 #[test]
468 fn builder_creates_file_nodes() {
469 let mut builder = GraphBuilder::new(SnapshotId(1));
470 let path = PathBuf::from("src/main.py");
471
472 let id1 = builder.add_file(path.clone(), LangId::Python);
473 let id2 = builder.add_file(path, LangId::Python);
474
475 assert_eq!(id1, id2, "same path should return same FileId");
476 assert_eq!(builder.node_count(), 1);
477 }
478
479 #[test]
480 fn builder_creates_symbol_with_ownership() {
481 let mut builder = GraphBuilder::new(SnapshotId(1));
482 let path = PathBuf::from("test.py");
483
484 let file_id = builder.add_file(path.clone(), LangId::Python);
485 let symbol = test_symbol(1, "hello", file_id.0);
486
487 let _sym_idx = builder.add_symbol(&symbol).unwrap();
488
489 assert_eq!(builder.node_count(), 2); assert_eq!(builder.edge_count(), 1); }
492
493 #[test]
494 fn builder_deduplicates_edges() {
495 let mut builder = GraphBuilder::new(SnapshotId(1));
496 let path1 = PathBuf::from("a.py");
497 let path2 = PathBuf::from("b.py");
498
499 let file1 = builder.add_file(path1, LangId::Python);
500 let _file2 = builder.add_file(path2, LangId::Python);
501
502 builder.add_import(file1, PathBuf::from("b.py"));
504 builder.add_import(file1, PathBuf::from("b.py"));
505
506 assert_eq!(
507 builder.edge_count(),
508 1,
509 "duplicate edges should be deduplicated"
510 );
511 }
512
513 #[test]
514 fn builder_creates_external_node_for_unknown_import() {
515 let mut builder = GraphBuilder::new(SnapshotId(1));
516 let path = PathBuf::from("main.py");
517
518 let file_id = builder.add_file(path, LangId::Python);
519
520 builder.add_import(file_id, PathBuf::from("external_module.py"));
522
523 assert_eq!(
525 builder.edge_count(),
526 1,
527 "external import should create an edge"
528 );
529 assert_eq!(builder.external_count(), 1, "should have one external node");
530 }
531
532 #[test]
533 fn builder_tracks_node_mappings() {
534 let mut builder = GraphBuilder::new(SnapshotId(1));
535 let path = PathBuf::from("test.py");
536
537 let file_id = builder.add_file(path, LangId::Python);
538 let symbol = test_symbol(42, "func", file_id.0);
539
540 builder.add_symbol(&symbol).unwrap();
541
542 assert!(builder.file_to_index.contains_key(&file_id));
544 assert!(builder.symbol_to_index.contains_key(&SymbolId(42)));
545 }
546
547 #[test]
548 fn from_extractions_builds_graph_with_correct_node_count() {
549 use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
550 use std::path::PathBuf;
551 let sym = Symbol {
552 id: SymbolId(1),
553 name: "foo".into(),
554 kind: SymbolKind::Function,
555 language: LangId::Python,
556 file_path: PathBuf::from("/proj/a.py"),
557 source_range: SourceRange {
558 byte_start: 0,
559 byte_end: 10,
560 start: LineColumn { line: 0, column: 0 },
561 end: LineColumn {
562 line: 0,
563 column: 10,
564 },
565 },
566 visibility: None,
567 signature: None,
568 docstring: None,
569 is_async: false,
570 };
571 let extractions = vec![FileExtraction {
572 path: PathBuf::from("/proj/a.py"),
573 lang: LangId::Python,
574 symbols: vec![sym],
575 imports: vec![],
576 references: vec![],
577 diagnostics: vec![],
578 ast_node_count: 0,
579 }];
580 let mut diags = Vec::new();
581 let (graph, _scc) = GraphBuilder::from_extractions(
582 &extractions,
583 std::path::Path::new("/proj"),
584 SnapshotId(1),
585 &mut diags,
586 );
587 assert_eq!(graph.file_count(), 1);
588 assert_eq!(graph.symbol_count(), 1);
589 assert!(diags.is_empty());
590 }
591
592 #[test]
593 fn from_extractions_populates_scc_analysis() {
594 use crate::model::FileExtraction;
595 let extractions: Vec<FileExtraction> = vec![];
596 let mut diags = Vec::new();
597 let (graph, scc) = GraphBuilder::from_extractions(
598 &extractions,
599 std::path::Path::new("/proj"),
600 SnapshotId(1),
601 &mut diags,
602 );
603 assert_eq!(graph.node_count(), 0);
604 assert!(!scc.components.iter().any(|c| c.is_cyclic));
605 }
606
607 #[test]
608 fn from_extractions_accumulates_diagnostics_on_symbol_error() {
609 use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
610 use std::path::PathBuf;
611 let sym = Symbol {
612 id: SymbolId(99),
613 name: "orphan".into(),
614 kind: SymbolKind::Function,
615 language: LangId::Python,
616 file_path: PathBuf::from("/proj/missing.py"),
617 source_range: SourceRange {
618 byte_start: 0,
619 byte_end: 5,
620 start: LineColumn { line: 0, column: 0 },
621 end: LineColumn { line: 0, column: 5 },
622 },
623 visibility: None,
624 signature: None,
625 docstring: None,
626 is_async: false,
627 };
628 let extractions = vec![FileExtraction {
629 path: PathBuf::from("/proj/a.py"),
630 lang: LangId::Python,
631 symbols: vec![sym],
632 imports: vec![],
633 references: vec![],
634 diagnostics: vec![],
635 ast_node_count: 0,
636 }];
637 let mut diags = Vec::new();
638 let (_graph, _scc) = GraphBuilder::from_extractions(
639 &extractions,
640 std::path::Path::new("/proj"),
641 SnapshotId(1),
642 &mut diags,
643 );
644 assert!(!diags.is_empty(), "expected diagnostic for orphan symbol");
645 }
646
647 #[test]
648 fn from_extractions_resolves_cross_file_imports() {
649 use crate::model::{FileExtraction, LineColumn, SourceRange, UnresolvedImport};
650 use std::path::PathBuf;
651 let extractions = vec![
652 FileExtraction {
653 path: PathBuf::from("/proj/a.py"),
654 lang: LangId::Python,
655 symbols: vec![],
656 imports: vec![UnresolvedImport {
657 import_specifier: "b".into(),
658 alias: None,
659 symbol: None,
660 star: false,
661 range: SourceRange {
662 byte_start: 0,
663 byte_end: 1,
664 start: LineColumn { line: 0, column: 0 },
665 end: LineColumn { line: 0, column: 1 },
666 },
667 }],
668 references: vec![],
669 diagnostics: vec![],
670 ast_node_count: 0,
671 },
672 FileExtraction {
673 path: PathBuf::from("/proj/b.py"),
674 lang: LangId::Python,
675 symbols: vec![],
676 imports: vec![],
677 references: vec![],
678 diagnostics: vec![],
679 ast_node_count: 0,
680 },
681 ];
682 let mut diags = Vec::new();
683 let (graph, _scc) = GraphBuilder::from_extractions(
684 &extractions,
685 std::path::Path::new("/proj"),
686 SnapshotId(1),
687 &mut diags,
688 );
689 assert_eq!(graph.file_count(), 2);
690 let import_edges: Vec<_> = graph
691 .edges_of_kind(crate::graph::EdgeKind::Import)
692 .collect();
693 assert_eq!(
694 import_edges.len(),
695 1,
696 "expected import edge from a.py to b.py"
697 );
698 }
699}