Skip to content

Commit 74ea870

Browse files
authored
Add name_dependents reverse index for incremental invalidation (#646)
Build a reverse index during indexing that tracks which definitions, references, and names depend on each `NameId`. Name-to-name edges encode the dependency type at registration time: - `ChildName`: `parent_scope` relationship (structural dependency) - `NestedName`: `nesting` relationship (reference-only dependency) - `Definition/Reference`: direct dependents of the name This index will be consumed by the incremental invalidation engine to efficiently cascade changes without scanning the full graph.
1 parent 7362b15 commit 74ea870

File tree

5 files changed

+412
-7
lines changed

5 files changed

+412
-7
lines changed

rust/rubydex/src/indexing/local_graph.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::collections::hash_map::Entry;
33
use crate::diagnostic::{Diagnostic, Rule};
44
use crate::model::definitions::Definition;
55
use crate::model::document::Document;
6+
use crate::model::graph::NameDependent;
67
use crate::model::identity_maps::IdentityHashMap;
78
use crate::model::ids::{DefinitionId, NameId, ReferenceId, StringId, UriId};
89
use crate::model::name::{Name, NameRef};
@@ -18,6 +19,7 @@ type LocalGraphParts = (
1819
IdentityHashMap<NameId, NameRef>,
1920
IdentityHashMap<ReferenceId, ConstantReference>,
2021
IdentityHashMap<ReferenceId, MethodRef>,
22+
IdentityHashMap<NameId, Vec<NameDependent>>,
2123
);
2224

2325
#[derive(Debug)]
@@ -29,6 +31,7 @@ pub struct LocalGraph {
2931
names: IdentityHashMap<NameId, NameRef>,
3032
constant_references: IdentityHashMap<ReferenceId, ConstantReference>,
3133
method_references: IdentityHashMap<ReferenceId, MethodRef>,
34+
name_dependents: IdentityHashMap<NameId, Vec<NameDependent>>,
3235
}
3336

3437
impl LocalGraph {
@@ -42,6 +45,7 @@ impl LocalGraph {
4245
names: IdentityHashMap::default(),
4346
constant_references: IdentityHashMap::default(),
4447
method_references: IdentityHashMap::default(),
48+
name_dependents: IdentityHashMap::default(),
4549
}
4650
}
4751

@@ -70,6 +74,13 @@ impl LocalGraph {
7074
pub fn add_definition(&mut self, definition: Definition) -> DefinitionId {
7175
let definition_id = definition.id();
7276

77+
if let Some(name_id) = definition.name_id() {
78+
self.name_dependents
79+
.entry(*name_id)
80+
.or_default()
81+
.push(NameDependent::Definition(definition_id));
82+
}
83+
7384
if self.definitions.insert(definition_id, definition).is_some() {
7485
debug_assert!(false, "DefinitionId collision in local graph");
7586
}
@@ -117,6 +128,18 @@ impl LocalGraph {
117128
entry.get_mut().increment_ref_count(1);
118129
}
119130
Entry::Vacant(entry) => {
131+
if let Some(&parent_scope) = name.parent_scope().as_ref() {
132+
self.name_dependents
133+
.entry(parent_scope)
134+
.or_default()
135+
.push(NameDependent::ChildName(name_id));
136+
}
137+
if let Some(&nesting_id) = name.nesting().as_ref() {
138+
self.name_dependents
139+
.entry(nesting_id)
140+
.or_default()
141+
.push(NameDependent::NestedName(name_id));
142+
}
120143
entry.insert(NameRef::Unresolved(Box::new(name)));
121144
}
122145
}
@@ -133,6 +156,10 @@ impl LocalGraph {
133156

134157
pub fn add_constant_reference(&mut self, reference: ConstantReference) -> ReferenceId {
135158
let reference_id = reference.id();
159+
self.name_dependents
160+
.entry(*reference.name_id())
161+
.or_default()
162+
.push(NameDependent::Reference(reference_id));
136163

137164
if self.constant_references.insert(reference_id, reference).is_some() {
138165
debug_assert!(false, "ReferenceId collision in local graph");
@@ -172,6 +199,13 @@ impl LocalGraph {
172199
self.document.add_diagnostic(diagnostic);
173200
}
174201

202+
// Name dependents
203+
204+
#[must_use]
205+
pub fn name_dependents(&self) -> &IdentityHashMap<NameId, Vec<NameDependent>> {
206+
&self.name_dependents
207+
}
208+
175209
// Into parts
176210

177211
#[must_use]
@@ -184,6 +218,7 @@ impl LocalGraph {
184218
self.names,
185219
self.constant_references,
186220
self.method_references,
221+
self.name_dependents,
187222
)
188223
}
189224
}

rust/rubydex/src/indexing/ruby_indexer.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5917,3 +5917,82 @@ mod tests {
59175917
});
59185918
}
59195919
}
5920+
5921+
#[cfg(test)]
5922+
mod name_dependent_tests {
5923+
use crate::assert_dependents;
5924+
use crate::test_utils::LocalGraphTest;
5925+
5926+
fn index_source(source: &str) -> LocalGraphTest {
5927+
LocalGraphTest::new("file:///foo.rb", source)
5928+
}
5929+
5930+
#[test]
5931+
fn track_dependency_chain() {
5932+
let context = index_source(
5933+
"
5934+
module Bar; end
5935+
CONST = 1
5936+
CONST2 = CONST
5937+
5938+
module Foo
5939+
class Bar::Baz
5940+
CONST
5941+
end
5942+
5943+
CONST2
5944+
end
5945+
",
5946+
);
5947+
5948+
assert_dependents!(&context, "Bar", [ChildName("Baz")]);
5949+
assert_dependents!(&context, "Foo", [NestedName("Baz"), NestedName("CONST2")]);
5950+
assert_dependents!(&context, "Bar::Baz", [Definition("Baz"), NestedName("CONST")]);
5951+
}
5952+
5953+
#[test]
5954+
fn multi_level_chain() {
5955+
let context = index_source(
5956+
"
5957+
module Foo
5958+
module Bar
5959+
module Baz
5960+
end
5961+
end
5962+
end
5963+
",
5964+
);
5965+
5966+
assert_dependents!(&context, "Foo", [NestedName("Bar")]);
5967+
assert_dependents!(&context, "Bar", [NestedName("Baz")]);
5968+
}
5969+
5970+
#[test]
5971+
fn singleton_class() {
5972+
let context = index_source(
5973+
"
5974+
class Foo
5975+
class << self
5976+
def bar; end
5977+
end
5978+
end
5979+
",
5980+
);
5981+
5982+
assert_dependents!(&context, "Foo", [ChildName("<Foo>")]);
5983+
}
5984+
5985+
#[test]
5986+
fn nested_vs_compact() {
5987+
let context = index_source(
5988+
"
5989+
module Foo
5990+
class Bar; end
5991+
class Foo::Baz; end
5992+
end
5993+
",
5994+
);
5995+
5996+
assert_dependents!(&context, "Foo", [NestedName("Bar"), ChildName("Baz")]);
5997+
}
5998+
}

rust/rubydex/src/model/graph.rs

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ use crate::model::references::{ConstantReference, MethodRef};
1414
use crate::model::string_ref::StringRef;
1515
use crate::stats;
1616

17+
/// An entity whose validity depends on a particular `NameId`.
18+
/// Used as the value type in the `name_dependents` reverse index.
19+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20+
pub enum NameDependent {
21+
Definition(DefinitionId),
22+
Reference(ReferenceId),
23+
/// This name's `parent_scope` is the key name — structural dependency.
24+
ChildName(NameId),
25+
/// This name's `nesting` is the key name — reference-only dependency.
26+
NestedName(NameId),
27+
}
28+
1729
pub static BASIC_OBJECT_ID: LazyLock<DeclarationId> = LazyLock::new(|| DeclarationId::from("BasicObject"));
1830
pub static OBJECT_ID: LazyLock<DeclarationId> = LazyLock::new(|| DeclarationId::from("Object"));
1931
pub static MODULE_ID: LazyLock<DeclarationId> = LazyLock::new(|| DeclarationId::from("Module"));
@@ -41,6 +53,10 @@ pub struct Graph {
4153

4254
/// The position encoding used for LSP line/column locations. Not related to the actual encoding of the file
4355
position_encoding: Encoding,
56+
57+
/// Reverse index: for each `NameId`, which definitions, references, and child/nested names depend on it.
58+
/// Used during invalidation to efficiently find affected entities without scanning the full graph.
59+
name_dependents: IdentityHashMap<NameId, Vec<NameDependent>>,
4460
}
4561

4662
impl Graph {
@@ -55,6 +71,7 @@ impl Graph {
5571
constant_references: IdentityHashMap::default(),
5672
method_references: IdentityHashMap::default(),
5773
position_encoding: Encoding::default(),
74+
name_dependents: IdentityHashMap::default(),
5875
}
5976
}
6077

@@ -501,6 +518,11 @@ impl Graph {
501518
&self.names
502519
}
503520

521+
#[must_use]
522+
pub fn name_dependents(&self) -> &IdentityHashMap<NameId, Vec<NameDependent>> {
523+
&self.name_dependents
524+
}
525+
504526
/// Converts a `Resolved` `NameRef` back to `Unresolved`, preserving the original `Name` data.
505527
/// Returns the `DeclarationId` it was previously resolved to, if any.
506528
fn unresolve_name(&mut self, name_id: NameId) -> Option<DeclarationId> {
@@ -533,11 +555,34 @@ impl Graph {
533555
}
534556
}
535557

536-
/// Removes a name from the graph entirely.
558+
/// Removes a name from the graph and cleans up its name-to-name edges from parent names.
537559
fn remove_name(&mut self, name_id: NameId) {
560+
if let Some(name_ref) = self.names.get(&name_id) {
561+
let parent_scope = name_ref.parent_scope().as_ref().copied();
562+
let nesting = name_ref.nesting().as_ref().copied();
563+
564+
if let Some(ps_id) = parent_scope {
565+
self.remove_name_dependent(ps_id, NameDependent::ChildName(name_id));
566+
}
567+
if let Some(nesting_id) = nesting {
568+
self.remove_name_dependent(nesting_id, NameDependent::NestedName(name_id));
569+
}
570+
}
571+
self.name_dependents.remove(&name_id);
538572
self.names.remove(&name_id);
539573
}
540574

575+
/// Removes a specific dependent from the `name_dependents` entry for `name_id`,
576+
/// cleaning up the entry if no dependents remain.
577+
fn remove_name_dependent(&mut self, name_id: NameId, dependent: NameDependent) {
578+
if let Some(deps) = self.name_dependents.get_mut(&name_id) {
579+
deps.retain(|d| *d != dependent);
580+
if deps.is_empty() {
581+
self.name_dependents.remove(&name_id);
582+
}
583+
}
584+
}
585+
541586
/// Decrements the ref count for a name and removes it if the count reaches zero.
542587
///
543588
/// This does not recursively untrack `parent_scope` or `nesting` names.
@@ -687,7 +732,7 @@ impl Graph {
687732
/// Merges everything in `other` into this Graph. This method is meant to merge all graph representations from
688733
/// different threads, but not meant to handle updates to the existing global representation
689734
pub fn extend(&mut self, local_graph: LocalGraph) {
690-
let (uri_id, document, definitions, strings, names, constant_references, method_references) =
735+
let (uri_id, document, definitions, strings, names, constant_references, method_references, name_dependents) =
691736
local_graph.into_parts();
692737

693738
if self.documents.insert(uri_id, document).is_some() {
@@ -735,6 +780,15 @@ impl Graph {
735780
debug_assert!(false, "Method ReferenceId collision in global graph");
736781
}
737782
}
783+
784+
for (name_id, deps) in name_dependents {
785+
let global_deps = self.name_dependents.entry(name_id).or_default();
786+
for dep in deps {
787+
if !global_deps.contains(&dep) {
788+
global_deps.push(dep);
789+
}
790+
}
791+
}
738792
}
739793

740794
/// Updates the global representation with the information contained in `other`, handling deletions, insertions and
@@ -765,6 +819,7 @@ impl Graph {
765819
self.unresolve_reference(*ref_id);
766820

767821
if let Some(constant_ref) = self.constant_references.remove(ref_id) {
822+
self.remove_name_dependent(*constant_ref.name_id(), NameDependent::Reference(*ref_id));
768823
self.untrack_name(*constant_ref.name_id());
769824
}
770825
}
@@ -800,8 +855,9 @@ impl Graph {
800855
}
801856
}
802857

803-
if let Some(name_id) = self.definitions.get(def_id).unwrap().name_id() {
804-
self.untrack_name(*name_id);
858+
if let Some(name_id) = self.definitions.get(def_id).unwrap().name_id().copied() {
859+
self.remove_name_dependent(name_id, NameDependent::Definition(*def_id));
860+
self.untrack_name(name_id);
805861
}
806862
}
807863

@@ -999,7 +1055,7 @@ mod tests {
9991055
use crate::model::comment::Comment;
10001056
use crate::model::declaration::Ancestors;
10011057
use crate::test_utils::GraphTest;
1002-
use crate::{assert_descendants, assert_members_eq, assert_no_diagnostics, assert_no_members};
1058+
use crate::{assert_dependents, assert_descendants, assert_members_eq, assert_no_diagnostics, assert_no_members};
10031059

10041060
#[test]
10051061
fn deleting_a_uri() {
@@ -1021,6 +1077,55 @@ mod tests {
10211077
);
10221078
}
10231079

1080+
#[test]
1081+
fn deleting_file_triggers_name_dependent_cleanup() {
1082+
let mut context = GraphTest::new();
1083+
1084+
context.index_uri(
1085+
"file:///foo.rb",
1086+
"
1087+
module Foo
1088+
CONST
1089+
end
1090+
",
1091+
);
1092+
context.index_uri(
1093+
"file:///bar.rb",
1094+
"
1095+
module Foo
1096+
class Bar; end
1097+
end
1098+
",
1099+
);
1100+
context.resolve();
1101+
1102+
assert_dependents!(
1103+
&context,
1104+
"Foo",
1105+
[
1106+
Definition("Foo"),
1107+
Definition("Foo"),
1108+
NestedName("Bar"),
1109+
NestedName("CONST"),
1110+
]
1111+
);
1112+
1113+
// Deleting bar.rb removes Bar's name (and its NestedName edge from Foo)
1114+
// and one Definition dependent (bar.rb's `module Foo` definition).
1115+
context.delete_uri("file:///bar.rb");
1116+
assert_dependents!(&context, "Foo", [Definition("Foo"), NestedName("CONST")]);
1117+
1118+
// Deleting foo.rb cleans up everything
1119+
context.delete_uri("file:///foo.rb");
1120+
let foo_ids = context
1121+
.graph()
1122+
.names()
1123+
.iter()
1124+
.filter(|(_, n)| *n.str() == StringId::from("Foo"))
1125+
.count();
1126+
assert_eq!(foo_ids, 0, "Foo name should be removed after deleting both files");
1127+
}
1128+
10241129
#[test]
10251130
fn updating_index_with_deleted_definitions() {
10261131
let mut context = GraphTest::new();

0 commit comments

Comments
 (0)