Skip to main content

meta_ast/graph/
builder.rs

1// Graph builder for incremental construction from extraction results.
2//!
3//! Construction proceeds in two phases:
4//! 1. Ownership graph: files and their symbols
5//! 2. Dependency graph: imports and cross-file references
6//!
7//! The builder maintains bidirectional mappings between domain IDs
8//! (FileId, SymbolId) and petgraph NodeIndex for efficient lookups.
9
10use 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/// Builder for incremental graph construction from extraction results.
23#[derive(Debug)]
24pub struct GraphBuilder {
25    /// Underlying graph being constructed
26    graph: DiGraph<NodeData, EdgeData>,
27
28    /// Map from FileId to graph node index
29    file_to_index: HashMap<FileId, NodeIndex>,
30
31    /// Map from SymbolId to graph node index
32    symbol_to_index: HashMap<SymbolId, NodeIndex>,
33
34    /// Map from file path to FileId (for resolving import targets)
35    path_to_file: HashMap<PathBuf, FileId>,
36
37    /// ID generator for FileIds
38    file_id_gen: IdGenerator<FileId>,
39
40    /// Snapshot ID for this analysis
41    snapshot_id: SnapshotId,
42
43    /// Counter for edge deduplication
44    edge_normalizer: EdgeNormalizer,
45
46    /// Map from external raw path to graph node index
47    external_index: HashMap<String, NodeIndex>,
48}
49
50/// Tracks seen edges to prevent duplicates.
51#[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    /// Creates a new GraphBuilder for the given snapshot.
64    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    /// Adds a file node to the graph.
78    ///
79    /// If the file already exists (determined by path), returns the existing FileId.
80    pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
81        // Check if file already exists
82        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    /// Adds a symbol node and its ownership edge to the containing file.
104    ///
105    /// The file must already exist in the builder; this is enforced by the
106    /// FileId parameter. The symbol's file_id must match a previously added file.
107    ///
108    /// Returns the NodeIndex for the symbol node.
109    pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
110        // Check if symbol already exists
111        if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
112            return Ok(existing);
113        }
114
115        // Look up the file by path
116        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        // Add ownership edge: file -> symbol (always full confidence)
139        self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
140
141        Ok(sym_idx)
142    }
143
144    /// Adds an import edge from one file to another.
145    ///
146    /// If the target file exists in the project, creates an import edge.
147    /// If the target is external (not in the project), creates an ExternalNode
148    /// placeholder and an import edge to it.
149    pub fn add_import(&mut self, from: FileId, to: PathBuf) {
150        let Some(&from_idx) = self.file_to_index.get(&from) else {
151            return; // Source not in graph
152        };
153
154        // Resolve target path to file ID if it exists in our graph
155        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        // External dependency: create or reuse external node
163        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            // Determine language from source file
168            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    /// Adds a MetaCall load edge between files.
191    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            // Resolve to FileId if it exists
209            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                // External or not discovered
219                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    /// Adds a reference edge between two symbols with a confidence score.
246    ///
247    /// Both symbols must exist in the builder. If a duplicate edge
248    /// already exists, the confidence is merged via max (the stronger
249    /// signal is preserved).
250    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    /// Internal method to add an edge with deduplication and confidence.
262    ///
263    /// If a duplicate `(source, target, kind)` already exists, the existing
264    /// edge's confidence is bumped to `max(existing, new)` - never reduced.
265    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            // Duplicate edge: max-merge confidence on the existing edge.
274            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    /// Returns the FileId for a given file path, if registered.
289    pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
290        self.path_to_file.get(path).copied()
291    }
292
293    /// Builds and returns an adjacency map from FileId to the FileIds it imports.
294    ///
295    /// Walks all Import edges in the graph to produce the relationship.
296    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    /// Finalizes the graph and returns the constructed CodeGraph.
316    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    /// Returns the number of nodes in the graph so far.
327    pub fn node_count(&self) -> usize {
328        self.graph.node_count()
329    }
330
331    /// Returns the number of edges in the graph so far.
332    pub fn edge_count(&self) -> usize {
333        self.graph.edge_count()
334    }
335
336    /// Returns the number of external dependency nodes.
337    pub fn external_count(&self) -> usize {
338        self.external_index.len()
339    }
340
341    /// Assemble a complete CodeGraph + SCC from parallel extraction results.
342    ///
343    /// Replaces the manual multi-step wiring in pipeline.rs. Handles:
344    /// - File and symbol node registration
345    /// - Import edge resolution (via language-specific resolvers)
346    /// - Cross-file reference resolution via FlattenedScopeCache
347    /// - SCC analysis
348    ///
349    /// Errors during symbol addition are non-fatal and appended to `diagnostics`.
350    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        // Phase 1: register all files
359        for file in extractions {
360            builder.add_file(file.path.clone(), file.lang);
361        }
362
363        // Phase 2: register all symbols; symbol errors are non-fatal
364        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        // Phase 3: build path -> FileId map needed by the resolver
378        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        // Phase 4: resolve language-specific import paths and add import edges
388        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        // Phase 5: cross-file reference resolution via FlattenedScopeCache
410        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        // Phase 6: finalize and compute SCC
428        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); // file + symbol
490        assert_eq!(builder.edge_count(), 1); // ownership edge
491    }
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        // Add same import twice
503        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        // Import of external module not in our project
521        builder.add_import(file_id, PathBuf::from("external_module.py"));
522
523        // Should create an external node and import edge
524        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        // Verify mappings exist
543        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}