use std::collections::{HashMap, HashSet};
use crate::error::{CodegraphError, Result};
use crate::graph::types::{CodeGraph, Edge, FileFacts, Symbol};
use crate::validate_file_facts;
use super::delta::FileChange;
use super::state::{PendingRefId, PendingState};
use super::stitch::GlobalIndex;
use super::subgraph::{FILE_SUBGRAPH_SCHEMA_VERSION, FileSubgraph, build_subgraph};
enum PreparedChange {
Upsert {
file: String,
subgraph: FileSubgraph,
},
Remove {
file: String,
},
}
pub(crate) struct MutationBounds {
pub(crate) before_symbols: Vec<Symbol>,
pub(crate) after_symbols: Vec<Symbol>,
pub(crate) before_edges: Vec<Edge>,
pub(crate) after_edges: Vec<Edge>,
}
pub struct IncrementalGraph {
files: HashMap<String, FileSubgraph>,
index: GlobalIndex,
pending_state: PendingState,
}
impl IncrementalGraph {
pub fn new() -> Self {
Self {
files: HashMap::new(),
index: GlobalIndex::new(),
pending_state: PendingState::default(),
}
}
pub fn from_files(files: &[FileFacts]) -> Self {
let mut store = Self::new();
let changes: Vec<FileChange<'_>> = files.iter().map(FileChange::Upsert).collect();
if store.try_apply_changes(&changes).is_err() {
store = Self::new();
for f in files {
store.upsert(f);
}
}
store
}
pub fn upsert(&mut self, facts: &FileFacts) {
let _ = self.try_apply_changes(&[FileChange::Upsert(facts)]);
}
pub fn subgraph(&self, file: &str) -> Option<&FileSubgraph> {
self.files.get(file)
}
pub fn upsert_subgraph(&mut self, file: String, sub: FileSubgraph) {
let _ = self.try_upsert_subgraph(file, sub);
}
pub fn try_upsert_subgraph(&mut self, file: String, sub: FileSubgraph) -> Result<()> {
let prepared = Self::prepare_restored_change(file, sub)?;
self.commit_prepared_bounded(std::iter::once(prepared));
Ok(())
}
pub(crate) fn try_apply_changes(&mut self, changes: &[FileChange<'_>]) -> Result<()> {
self.try_apply_changes_bounded(changes).map(|_| ())
}
pub(crate) fn try_apply_changes_bounded(
&mut self,
changes: &[FileChange<'_>],
) -> Result<MutationBounds> {
let prepared = Self::prepare_changes(changes)?;
Ok(self.commit_prepared_bounded(prepared))
}
pub fn remove(&mut self, file: &str) {
let _ = self.try_apply_changes(&[FileChange::Remove(file)]);
}
fn prepare_changes(changes: &[FileChange<'_>]) -> Result<Vec<PreparedChange>> {
let mut targets = HashSet::with_capacity(changes.len());
for change in changes {
let file = match change {
FileChange::Upsert(facts) => facts.file.as_str(),
FileChange::Remove(file) => file,
};
if !targets.insert(file) {
return Err(CodegraphError::MalformedFacts {
file: file.to_owned(),
reason: "duplicate batch mutation target".into(),
});
}
}
let mut prepared = Vec::with_capacity(changes.len());
for change in changes {
match change {
FileChange::Upsert(facts) => {
validate_file_facts(std::slice::from_ref(*facts))?;
prepared.push(PreparedChange::Upsert {
file: facts.file.clone(),
subgraph: build_subgraph(facts),
});
}
FileChange::Remove(file) => prepared.push(PreparedChange::Remove {
file: (*file).to_owned(),
}),
}
}
Ok(prepared)
}
fn prepare_restored_change(file: String, subgraph: FileSubgraph) -> Result<PreparedChange> {
Self::validate_restored_subgraph(&file, &subgraph)?;
Ok(PreparedChange::Upsert { file, subgraph })
}
fn validate_restored_subgraph(file: &str, sub: &FileSubgraph) -> Result<()> {
let invalid = |reason: String| CodegraphError::MalformedFacts {
file: file.to_owned(),
reason,
};
if sub.schema_version != FILE_SUBGRAPH_SCHEMA_VERSION {
return Err(invalid(format!(
"unsupported subgraph schema {}",
sub.schema_version
)));
}
if sub.owner_file != file {
return Err(invalid("subgraph owner does not match restore key".into()));
}
if sub.symbols.iter().any(|symbol| symbol.file != file)
|| sub.intra_edges.iter().any(|edge| edge.occ.file != file)
|| sub.pending.iter().any(|pending| pending.occ.file != file)
{
return Err(invalid(
"subgraph contains facts owned by another file".into(),
));
}
let owned_symbols: HashSet<_> = sub.symbols.iter().map(|symbol| &symbol.id).collect();
if sub.intra_edges.iter().any(|edge| {
!owned_symbols.contains(&edge.from)
|| match edge.to.local_file() {
Some(owner) => owner != file,
None => !owned_symbols.contains(&edge.to),
}
}) || sub
.pending
.iter()
.any(|pending| !owned_symbols.contains(&pending.from))
{
return Err(invalid(
"subgraph contains edges or references outside its owner".into(),
));
}
Ok(())
}
fn commit_prepared_bounded(
&mut self,
changes: impl IntoIterator<Item = PreparedChange>,
) -> MutationBounds {
let changes: Vec<_> = changes.into_iter().collect();
let targets: HashSet<String> = changes
.iter()
.map(|change| match change {
PreparedChange::Upsert { file, .. } | PreparedChange::Remove { file } => {
file.clone()
}
})
.collect();
let mut affected = HashSet::new();
for change in &changes {
match change {
PreparedChange::Upsert { file, subgraph } => {
if let Some(old) = self.files.get(file) {
for symbol in &old.symbols {
affected.extend(self.pending_state.affected_by_symbol(symbol));
}
}
for symbol in &subgraph.symbols {
affected.extend(self.pending_state.affected_by_symbol(symbol));
}
}
PreparedChange::Remove { file } => {
if let Some(old) = self.files.get(file) {
for symbol in &old.symbols {
affected.extend(self.pending_state.affected_by_symbol(symbol));
}
}
}
}
}
let reexports_changed = changes.iter().any(|change| match change {
PreparedChange::Upsert { file, subgraph } => self
.files
.get(file)
.is_none_or(|old| old.reexports != subgraph.reexports),
PreparedChange::Remove { file } => self
.files
.get(file)
.is_some_and(|old| !old.reexports.is_empty()),
});
if reexports_changed {
affected.extend(self.pending_state.all_ids().cloned());
}
let before_symbols = self.bound_symbols(&targets);
let before_edges = self.bound_edges(&targets, &affected);
for change in changes {
match change {
PreparedChange::Upsert { file, subgraph } => {
if let Some(old) = self.files.get(&file) {
self.index.remove_subgraph(old);
}
self.index.insert_subgraph(&subgraph);
self.files.insert(file, subgraph);
}
PreparedChange::Remove { file } => {
if let Some(old) = self.files.remove(&file) {
self.index.remove_subgraph(&old);
}
}
}
}
for owner in &targets {
self.pending_state.remove_owner(owner);
if let Some(subgraph) = self.files.get(owner) {
affected.extend(self.pending_state.install(owner, &subgraph.pending));
}
}
self.pending_state.resolve(affected.clone(), &self.index);
let after_symbols = self.bound_symbols(&targets);
let after_edges = self.bound_edges(&targets, &affected);
MutationBounds {
before_symbols,
after_symbols,
before_edges,
after_edges,
}
}
fn bound_symbols(&self, targets: &HashSet<String>) -> Vec<Symbol> {
targets
.iter()
.filter_map(|file| self.files.get(file))
.flat_map(|subgraph| subgraph.symbols.iter().cloned())
.collect()
}
fn bound_edges(
&self,
targets: &HashSet<String>,
affected: &HashSet<PendingRefId>,
) -> Vec<Edge> {
let mut edges = Vec::new();
for owner in targets {
if let Some(subgraph) = self.files.get(owner) {
edges.extend(subgraph.intra_edges.iter().cloned());
for id in self.pending_state.owner_ids(owner) {
if let Some(Some(edge)) = self.pending_state.resolved_id(id) {
edges.push(edge.clone());
}
}
}
}
for id in affected {
if targets.contains(id.owner()) {
continue;
}
if let Some(Some(edge)) = self.pending_state.resolved_id(id) {
edges.push(edge.clone());
}
}
edges
}
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 seen_symbols = HashSet::new();
for (_, sub) in entries {
symbols.extend(
sub.symbols
.iter()
.filter(|s| seen_symbols.insert(s.id.clone()))
.cloned(),
);
edges.extend(sub.intra_edges.iter().cloned());
for (ordinal, _) in sub.pending.iter().enumerate() {
if let Some(Some(edge)) = self.pending_state.resolved(&sub.owner_file, ordinal) {
edges.push(edge.clone());
}
}
}
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(all(
test,
any(feature = "rust", feature = "python", feature = "ruby", feature = "go")
))]
mod tests {
use super::*;
#[cfg(any(feature = "rust", feature = "python", feature = "ruby", feature = "go"))]
use crate::extract::Extractor;
#[cfg(feature = "python")]
use crate::extract::PythonExtractor;
#[cfg(feature = "rust")]
use crate::extract::RustExtractor;
use crate::graph::types::{CodeGraph, Confidence, Edge, EdgeKey};
#[cfg(feature = "rust")]
use crate::resolve::SymbolTableResolver;
use crate::resolve::{Resolver, ScopeGraphResolver};
fn edge_key(e: &Edge) -> (EdgeKey, Confidence) {
(e.key(), e.confidence)
}
fn counts<K: Eq + std::hash::Hash>(keys: impl IntoIterator<Item = K>) -> HashMap<K, usize> {
let mut counts = HashMap::new();
for key in keys {
*counts.entry(key).or_default() += 1;
}
counts
}
fn assert_multiset_eq(a: &CodeGraph, b: &CodeGraph) {
let a_syms = counts(a.symbols.iter().map(|s| s.id.clone()));
let b_syms = counts(b.symbols.iter().map(|s| s.id.clone()));
assert_eq!(a_syms, b_syms, "symbol multisets differ");
let a_edges = counts(a.edges.iter().map(edge_key));
let b_edges = counts(b.edges.iter().map(edge_key));
assert_eq!(a_edges, b_edges, "edge multisets differ");
}
#[cfg(feature = "rust")]
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]
}
#[cfg(feature = "rust")]
#[test]
fn removing_reexport_restitches_type_consumers() {
let definition = RustExtractor
.extract("pub struct Config {}", "src/inner.rs")
.unwrap();
let reexport = RustExtractor
.extract("pub use super::inner::Config;", "src/api/mod.rs")
.unwrap();
let consumer = RustExtractor
.extract(
"pub struct Use { config: crate::api::Config }",
"src/use.rs",
)
.unwrap();
let mut graph = IncrementalGraph::from_files(&[definition, reexport, consumer]);
assert!(
graph
.graph()
.edges
.iter()
.any(|edge| edge.role == crate::RefRole::TypeRef)
);
graph.remove("src/api/mod.rs");
assert!(
!graph
.graph()
.edges
.iter()
.any(|edge| edge.role == crate::RefRole::TypeRef),
"removing an alias must clear dependent type edges"
);
}
#[cfg(feature = "rust")]
#[test]
fn incremental_matches_batch_same_set() {
let files = rust_set();
let store = IncrementalGraph::from_files(&files);
let batch = ScopeGraphResolver.resolve(&files).unwrap();
assert_multiset_eq(&store.graph(), &batch);
}
#[cfg(feature = "rust")]
#[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()])
.unwrap();
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]).unwrap();
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"
);
}
#[cfg(feature = "go")]
#[test]
fn graph_dedupes_shared_package_module_symbol() {
use crate::extract::GoExtractor;
use crate::graph::types::SymbolKind;
let util = GoExtractor
.extract("package main\nfunc Helper() {}\n", "util.go")
.unwrap();
let main = GoExtractor
.extract("package main\nfunc Run() {\n\tHelper()\n}\n", "main.go")
.unwrap();
let store = IncrementalGraph::from_files(&[util, main]);
let g = store.graph();
let module_ids: Vec<_> = g
.symbols
.iter()
.filter(|s| s.kind == SymbolKind::Module && s.name == "main")
.map(|s| &s.id)
.collect();
assert_eq!(
module_ids.len(),
1,
"expected exactly one package module symbol for `main`, got {module_ids:?}"
);
}
#[cfg(feature = "python")]
#[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]).unwrap();
assert_multiset_eq(&store.graph(), &batch);
}
#[cfg(feature = "rust")]
#[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]).unwrap();
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"
);
let before_missing_remove = store.graph();
store.remove("src/missing.rs");
assert_eq!(store.len(), 2);
assert_multiset_eq(&store.graph(), &before_missing_remove);
}
#[cfg(all(feature = "serde", feature = "rust"))]
#[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());
}
#[cfg(feature = "rust")]
#[test]
fn restoring_a_subgraph_under_a_different_key_leaves_existing_state_unchanged() {
let original = RustExtractor
.extract("pub fn original() {}", "src/original.rs")
.unwrap();
let replacement = RustExtractor
.extract("pub fn replacement() {}", "src/replacement.rs")
.unwrap();
let mut store = IncrementalGraph::from_files(&[original]);
let before = store.graph();
assert!(
store
.try_upsert_subgraph("src/other.rs".to_string(), build_subgraph(&replacement))
.is_err()
);
assert_eq!(store.len(), 1);
assert!(store.subgraph("src/original.rs").is_some());
assert!(store.subgraph("src/other.rs").is_none());
assert_multiset_eq(&store.graph(), &before);
}
#[cfg(feature = "rust")]
#[test]
fn restoring_a_subgraph_with_a_foreign_caller_leaves_state_unchanged() {
let consumer = RustExtractor
.extract(
"use provider::value;\npub fn call() { value(); }",
"src/consumer.rs",
)
.unwrap();
let mut subgraph = build_subgraph(&consumer);
let pending = subgraph
.pending
.first_mut()
.expect("imported call must produce a pending reference");
pending.from = crate::symbol::SymbolId::local("src/other.rs", "injected");
let mut store = IncrementalGraph::new();
let before = store.graph();
assert!(
store
.try_upsert_subgraph("src/consumer.rs".to_string(), subgraph)
.is_err()
);
assert!(store.is_empty());
assert!(store.subgraph("src/consumer.rs").is_none());
assert_multiset_eq(&store.graph(), &before);
}
#[cfg(feature = "rust")]
#[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);
}
#[cfg(feature = "rust")]
#[test]
fn stored_pending_state_keeps_duplicate_occurrences_and_reconciles_provider_changes() {
let consumer = RustExtractor
.extract(
"use provider::helper;\npub fn run() { helper(); helper(); }",
"src/consumer.rs",
)
.unwrap();
let provider = RustExtractor
.extract("pub fn helper() {}", "src/provider.rs")
.unwrap();
let removed = RustExtractor
.extract("pub fn other() {}", "src/provider.rs")
.unwrap();
let mut store = IncrementalGraph::from_files(std::slice::from_ref(&consumer));
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(std::slice::from_ref(&consumer))
.unwrap(),
);
store.upsert(&provider);
let resolved = store.graph();
assert_eq!(
resolved
.edges
.iter()
.filter(|edge| edge.role == crate::graph::types::RefRole::Call)
.count(),
2,
"identical pending call occurrences must retain distinct ordinals"
);
assert_multiset_eq(
&resolved,
&ScopeGraphResolver
.resolve(&[consumer.clone(), provider.clone()])
.unwrap(),
);
store.upsert(&removed);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), removed])
.unwrap(),
);
store.remove("src/provider.rs");
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver.resolve(&[consumer]).unwrap(),
);
}
#[cfg(feature = "rust")]
#[test]
fn checked_batch_is_atomic_when_a_later_upsert_is_malformed() {
let original = RustExtractor
.extract("pub fn original() {}", "src/original.rs")
.unwrap();
let consumer = RustExtractor
.extract(
"use original::original;\npub fn call() { original(); }",
"src/consumer.rs",
)
.unwrap();
let replacement = RustExtractor
.extract("pub fn replacement() {}", "src/original.rs")
.unwrap();
let mut malformed = RustExtractor
.extract("pub fn malformed() {}", "src/new.rs")
.unwrap();
malformed.scopes[0].parent = Some(malformed.scopes.len());
let mut store = IncrementalGraph::from_files(&[original, consumer]);
let before = store.graph();
assert!(
store
.try_apply_changes(&[
FileChange::Upsert(&replacement),
FileChange::Upsert(&malformed),
])
.is_err()
);
assert_eq!(store.len(), 2);
assert!(store.subgraph("src/original.rs").is_some());
assert!(store.subgraph("src/new.rs").is_none());
assert_multiset_eq(&store.graph(), &before);
}
#[cfg(feature = "rust")]
#[test]
fn checked_batch_rejects_duplicate_and_conflicting_targets_without_mutation() {
let existing = RustExtractor
.extract("pub fn existing() {}", "src/existing.rs")
.unwrap();
let replacement = RustExtractor
.extract("pub fn replacement() {}", "src/existing.rs")
.unwrap();
let mut malformed_duplicate = replacement.clone();
malformed_duplicate.scopes[0].parent = Some(malformed_duplicate.scopes.len());
let mut store = IncrementalGraph::from_files(&[existing]);
let before = store.graph();
let error = store
.try_apply_changes(&[
FileChange::Upsert(&malformed_duplicate),
FileChange::Upsert(&malformed_duplicate),
])
.expect_err("duplicate targets must be rejected before preparation");
assert!(matches!(
error,
CodegraphError::MalformedFacts { reason, .. }
if reason == "duplicate batch mutation target"
));
assert_eq!(store.len(), 1);
assert!(store.subgraph("src/existing.rs").is_some());
assert_multiset_eq(&store.graph(), &before);
assert!(
store
.try_apply_changes(&[
FileChange::Remove("src/existing.rs"),
FileChange::Remove("src/existing.rs"),
])
.is_err()
);
assert_eq!(store.len(), 1);
assert!(store.subgraph("src/existing.rs").is_some());
assert_multiset_eq(&store.graph(), &before);
assert!(
store
.try_apply_changes(&[
FileChange::Upsert(&replacement),
FileChange::Remove("src/existing.rs"),
])
.is_err()
);
assert_eq!(store.len(), 1);
assert!(store.subgraph("src/existing.rs").is_some());
assert_multiset_eq(&store.graph(), &before);
}
#[cfg(feature = "rust")]
#[test]
fn typeref_module_preference_and_ordinary_fallback_restitch_unchanged_owner() {
let consumer = RustExtractor
.extract("pub struct Order { value: Config }", "src/order.rs")
.unwrap();
let ordinary = RustExtractor
.extract("pub struct Config {}", "src/types.rs")
.unwrap();
let module = RustExtractor.extract("", "src/Config.rs").unwrap();
let mut store = IncrementalGraph::from_files(&[consumer.clone(), ordinary.clone()]);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), ordinary.clone()])
.unwrap(),
);
store.upsert(&module);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), ordinary.clone(), module])
.unwrap(),
);
store.remove("src/Config.rs");
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver.resolve(&[consumer, ordinary]).unwrap(),
);
}
#[cfg(feature = "rust")]
#[test]
fn qualified_namespace_candidate_add_remove_restitches_unchanged_owner() {
let consumer = RustExtractor
.extract("pub fn run() { a::process() }", "src/consumer.rs")
.unwrap();
let first = RustExtractor
.extract("pub fn process() {}", "src/a.rs")
.unwrap();
let second = RustExtractor
.extract("pub fn process() {}", "src/other/a.rs")
.unwrap();
let mut store = IncrementalGraph::from_files(&[consumer.clone(), first.clone()]);
store.upsert(&second);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), first.clone(), second.clone()])
.unwrap(),
);
store.remove("src/other/a.rs");
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver.resolve(&[consumer, first]).unwrap(),
);
}
#[cfg(feature = "ruby")]
#[test]
fn qualified_enclosing_type_candidate_restitches_unchanged_owner() {
use crate::extract::RubyExtractor;
let consumer = RubyExtractor
.extract("def run\n Alpha.compute\nend\n", "main.rb")
.unwrap();
let first = RubyExtractor
.extract(
"module Alpha\n def self.compute\n 1\n end\nend\n",
"alpha.rb",
)
.unwrap();
let duplicate = RubyExtractor
.extract(
"module Alpha\n def self.compute\n 2\n end\nend\n",
"duplicate.rb",
)
.unwrap();
let mut store = IncrementalGraph::from_files(&[consumer.clone(), first.clone()]);
store.upsert(&duplicate);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), first.clone(), duplicate.clone()])
.unwrap(),
);
store.remove("duplicate.rb");
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver.resolve(&[consumer, first]).unwrap(),
);
}
#[cfg(feature = "rust")]
#[test]
fn module_candidate_add_remove_restitches_unchanged_owner() {
let consumer = RustExtractor
.extract("mod util;\npub fn run() {}", "src/lib.rs")
.unwrap();
let first = RustExtractor
.extract("pub fn first() {}", "src/a/util.rs")
.unwrap();
let second = RustExtractor
.extract("pub fn second() {}", "src/b/util.rs")
.unwrap();
let mut store = IncrementalGraph::from_files(&[consumer.clone(), first.clone()]);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), first.clone()])
.unwrap(),
);
store.upsert(&second);
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), first.clone(), second.clone()])
.unwrap(),
);
store.remove("src/b/util.rs");
assert_multiset_eq(
&store.graph(),
&ScopeGraphResolver.resolve(&[consumer, first]).unwrap(),
);
}
#[cfg(feature = "go")]
#[test]
fn checked_batch_resolves_only_final_state_independent_of_change_order() {
use crate::extract::GoExtractor;
let consumer = GoExtractor
.extract("package main\nfunc Run() { Helper() }\n", "consumer.go")
.unwrap();
let a_old = GoExtractor
.extract("package main\nfunc Helper() {}\n", "a.go")
.unwrap();
let a_new = GoExtractor
.extract("package main\nfunc Other() {}\n", "a.go")
.unwrap();
let b_old = GoExtractor
.extract("package main\nfunc Other() {}\n", "b.go")
.unwrap();
let b_new = GoExtractor
.extract("package main\nfunc Helper() {}\n", "b.go")
.unwrap();
let initial = [consumer.clone(), a_old, b_old];
let mut forward = IncrementalGraph::from_files(&initial);
let mut reverse = IncrementalGraph::from_files(&initial);
forward
.try_apply_changes(&[FileChange::Upsert(&a_new), FileChange::Upsert(&b_new)])
.unwrap();
reverse
.try_apply_changes(&[FileChange::Upsert(&b_new), FileChange::Upsert(&a_new)])
.unwrap();
let batch = ScopeGraphResolver
.resolve(&[consumer, a_new, b_new])
.unwrap();
assert_multiset_eq(&forward.graph(), &batch);
assert_multiset_eq(&reverse.graph(), &batch);
let forward_graph = forward.graph();
let reverse_graph = reverse.graph();
assert_eq!(
forward_graph
.symbols
.iter()
.map(|symbol| symbol.id.clone())
.collect::<Vec<_>>(),
reverse_graph
.symbols
.iter()
.map(|symbol| symbol.id.clone())
.collect::<Vec<_>>()
);
assert_eq!(
forward_graph
.edges
.iter()
.map(Edge::key)
.collect::<Vec<_>>(),
reverse_graph
.edges
.iter()
.map(Edge::key)
.collect::<Vec<_>>()
);
}
#[cfg(feature = "rust")]
#[test]
fn restored_pending_state_tracks_later_provider_mutations() {
let consumer = RustExtractor
.extract(
"use provider::helper;\npub fn run() { helper(); }",
"src/consumer.rs",
)
.unwrap();
let provider = RustExtractor
.extract("pub fn helper() {}", "src/provider.rs")
.unwrap();
let replacement = RustExtractor
.extract("pub fn other() {}", "src/provider.rs")
.unwrap();
let consumer_sub = build_subgraph(&consumer);
let mut restored = IncrementalGraph::new();
restored
.try_upsert_subgraph("src/consumer.rs".into(), consumer_sub)
.unwrap();
restored.upsert(&provider);
assert_multiset_eq(
&restored.graph(),
&ScopeGraphResolver
.resolve(&[consumer.clone(), provider])
.unwrap(),
);
restored.upsert(&replacement);
assert_multiset_eq(
&restored.graph(),
&ScopeGraphResolver
.resolve(&[consumer, replacement])
.unwrap(),
);
}
#[cfg(feature = "rust")]
#[test]
fn checked_mixed_batch_matches_fresh_scope_graph_resolution() {
let old = RustExtractor
.extract("pub fn old() {}", "src/old.rs")
.unwrap();
let replaced = RustExtractor
.extract("pub fn old_version() {}", "src/replaced.rs")
.unwrap();
let replacement = RustExtractor
.extract("pub fn new_version() {}", "src/replaced.rs")
.unwrap();
let added = RustExtractor
.extract("pub fn added() {}", "src/added.rs")
.unwrap();
let mut store = IncrementalGraph::from_files(&[old, replaced]);
store
.try_apply_changes(&[
FileChange::Upsert(&replacement),
FileChange::Upsert(&added),
FileChange::Remove("src/old.rs"),
])
.unwrap();
let batch = ScopeGraphResolver.resolve(&[replacement, added]).unwrap();
assert_multiset_eq(&store.graph(), &batch);
}
}