use std::collections::HashMap;
use crate::graph::types::{CodeGraph, FileFacts};
use super::stitch::{GlobalIndex, stitch};
use super::subgraph::{FileSubgraph, build_subgraph};
pub struct IncrementalGraph {
files: HashMap<String, FileSubgraph>,
index: GlobalIndex,
}
impl IncrementalGraph {
pub fn new() -> Self {
Self {
files: HashMap::new(),
index: GlobalIndex::new(),
}
}
pub fn from_files(files: &[FileFacts]) -> Self {
let mut store = Self::new();
for f in files {
store.upsert(f);
}
store
}
pub fn upsert(&mut self, facts: &FileFacts) {
self.upsert_subgraph(facts.file.clone(), build_subgraph(facts));
}
pub fn subgraph(&self, file: &str) -> Option<&FileSubgraph> {
self.files.get(file)
}
pub fn upsert_subgraph(&mut self, file: String, sub: FileSubgraph) {
if let Some(old) = self.files.get(&file) {
self.index.remove_symbols(&old.symbols);
}
self.index.insert_symbols(&sub.symbols);
self.files.insert(file, sub);
}
pub fn remove(&mut self, file: &str) {
if let Some(old) = self.files.remove(file) {
self.index.remove_symbols(&old.symbols);
}
}
pub fn graph(&self) -> CodeGraph {
let mut entries: Vec<(&String, &FileSubgraph)> = self.files.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut symbols = Vec::new();
let mut edges = Vec::new();
let mut pending = Vec::new();
for (_, sub) in entries {
symbols.extend(sub.symbols.iter().cloned());
edges.extend(sub.intra_edges.iter().cloned());
pending.extend(sub.pending.iter().cloned());
}
edges.extend(stitch(&pending, &self.index));
CodeGraph { symbols, edges }
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
}
impl Default for IncrementalGraph {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{Extractor, PythonExtractor, RustExtractor};
use crate::graph::types::{CodeGraph, Edge};
use crate::resolve::{Resolver, ScopeGraphResolver, SymbolTableResolver};
fn edge_key(e: &Edge) -> (String, String, String, String, usize) {
(
e.from.to_scip_string(),
e.to.to_scip_string(),
format!("{:?}", e.role),
format!("{:?}", e.confidence),
e.occ.byte,
)
}
fn assert_multiset_eq(a: &CodeGraph, b: &CodeGraph) {
let mut a_syms: Vec<String> = a.symbols.iter().map(|s| s.id.to_scip_string()).collect();
let mut b_syms: Vec<String> = b.symbols.iter().map(|s| s.id.to_scip_string()).collect();
a_syms.sort();
b_syms.sort();
assert_eq!(a_syms, b_syms, "symbol multisets differ");
let mut a_edges: Vec<_> = a.edges.iter().map(edge_key).collect();
let mut b_edges: Vec<_> = b.edges.iter().map(edge_key).collect();
a_edges.sort();
b_edges.sort();
assert_eq!(a_edges, b_edges, "edge multisets differ");
}
fn rust_set() -> Vec<FileFacts> {
let conf = RustExtractor
.extract("pub struct Config {}", "src/conf.rs")
.unwrap();
let app = RustExtractor
.extract("use conf::Config;\npub fn run() {}", "src/app.rs")
.unwrap();
let util = RustExtractor
.extract(
"pub fn helper() {} pub fn run2() { let h = make(); h() }",
"src/util.rs",
)
.unwrap();
vec![conf, app, util]
}
#[test]
fn incremental_matches_batch_same_set() {
let files = rust_set();
let store = IncrementalGraph::from_files(&files);
let batch = ScopeGraphResolver.resolve(&files);
assert_multiset_eq(&store.graph(), &batch);
}
#[test]
fn duplicate_file_key_last_wins_matches_batch() {
let v1 = RustExtractor
.extract("pub fn first() {}", "src/app.rs")
.unwrap();
let v2 = RustExtractor
.extract("pub fn second() {}", "src/app.rs")
.unwrap();
let store = IncrementalGraph::from_files(&[v1.clone(), v2.clone()]);
let batch = ScopeGraphResolver.resolve(&[v1.clone(), v2.clone()]);
assert_multiset_eq(&store.graph(), &batch);
let g = store.graph();
assert!(
g.symbols
.iter()
.any(|s| s.id.to_scip_string().ends_with("second().")),
"last-wins must keep v2 (`second`), got: {:?}",
g.symbols
.iter()
.map(|s| s.id.to_scip_string())
.collect::<Vec<_>>()
);
assert!(
!g.symbols
.iter()
.any(|s| s.id.to_scip_string().ends_with("first().")),
"v1 (`first`) must not survive last-wins dedup"
);
let tier_a = SymbolTableResolver.resolve(&[v1, v2]);
let mut ids: Vec<String> = tier_a
.symbols
.iter()
.map(|s| s.id.to_scip_string())
.collect();
let total = ids.len();
ids.sort();
ids.dedup();
assert_eq!(
ids.len(),
total,
"duplicate file keys must not yield duplicate SymbolIds"
);
}
#[test]
fn reupsert_changed_file_matches_batch_of_new_set() {
let a = PythonExtractor
.extract("def process():\n pass\n", "alpha.py")
.unwrap();
let b = PythonExtractor
.extract(
"from alpha import process\n\ndef run():\n process()\n",
"main.py",
)
.unwrap();
let c = PythonExtractor
.extract("def process():\n pass\n", "beta.py")
.unwrap();
let mut store = IncrementalGraph::from_files(&[a.clone(), b, c.clone()]);
let b_new = PythonExtractor
.extract(
"from beta import process\n\ndef run():\n process()\n",
"main.py",
)
.unwrap();
store.upsert(&b_new);
let batch = ScopeGraphResolver.resolve(&[a, b_new, c]);
assert_multiset_eq(&store.graph(), &batch);
}
#[test]
fn remove_drops_only_that_file() {
let files = rust_set();
let mut store = IncrementalGraph::from_files(&files);
store.remove("src/app.rs");
let conf = files[0].clone();
let util = files[2].clone();
let batch = ScopeGraphResolver.resolve(&[conf, util]);
assert_multiset_eq(&store.graph(), &batch);
let g = store.graph();
assert!(
g.symbols.iter().all(|s| s.file != "src/app.rs"),
"removed file's symbols must be gone"
);
assert!(
g.edges.iter().all(|e| e.occ.file != "src/app.rs"),
"removed file's edges must be gone"
);
}
#[cfg(feature = "serde")]
#[test]
fn reload_from_serialized_subgraphs_matches_original() {
use crate::resolve::FileSubgraph;
let files = rust_set();
let store = IncrementalGraph::from_files(&files);
let file_keys = ["src/conf.rs", "src/app.rs", "src/util.rs"];
let mut restored = IncrementalGraph::new();
for key in file_keys {
let sub = store
.subgraph(key)
.unwrap_or_else(|| panic!("subgraph missing for {key}"));
let json =
serde_json::to_string(sub).unwrap_or_else(|e| panic!("serialize {key}: {e}"));
let deserialized: FileSubgraph =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize {key}: {e}"));
restored.upsert_subgraph(key.to_string(), deserialized);
}
assert_multiset_eq(&restored.graph(), &store.graph());
}
#[test]
fn upsert_is_idempotent() {
let files = rust_set();
let mut once = IncrementalGraph::new();
for f in &files {
once.upsert(f);
}
let once_graph = once.graph();
let mut twice = IncrementalGraph::new();
for f in &files {
twice.upsert(f);
}
for f in &files {
twice.upsert(f);
}
assert_multiset_eq(&twice.graph(), &once_graph);
}
}