Skip to content

Commit 4d03161

Browse files
committed
Defer cleanup of declarations emptied during invalidation
During invalidation, declarations can lose all their definitions via two paths that don't immediately remove them: 1. Name cascade (unresolve_dependent_name): a surviving file's definition is detached and re-queued — the declaration may be repopulated after re-resolution. 2. Document data cleanup (remove_document_data): the broad retain sweep detaches definitions that definition_to_declaration_id can't map back (e.g. singleton members like def self.x or @ivar in class body, where the lookup resolves to Foo but the member lives under <Foo>). In both cases, immediately removing the declaration would destroy accumulated state (singleton, members) that the resolver can rebuild. Instead, push emptied declarations to pending_declaration_cleanup and run cleanup after resolution — only removing those still empty. Add has_no_backing_definitions to Declaration, which combines the synthetic check with the empty-definitions check. Synthetic declarations (SingletonClass, Object, Module, Class) are never cleaned up this way — they're only removed when their owner is removed.
1 parent f610861 commit 4d03161

3 files changed

Lines changed: 166 additions & 21 deletions

File tree

rust/rubydex/src/model/declaration.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,18 @@ impl Declaration {
345345
all_declarations!(self, it => it.definition_ids.is_empty())
346346
}
347347

348+
/// Returns true if this declaration has no backing definitions and is eligible
349+
/// for removal. Synthetic declarations (singleton classes, bootstrap Object/Module/Class)
350+
/// are never definition-backed — they should only be removed when their owner is removed.
351+
#[must_use]
352+
pub fn has_no_backing_definitions(&self, decl_id: &DeclarationId) -> bool {
353+
let is_synthetic = matches!(self, Declaration::Namespace(Namespace::SingletonClass(_)))
354+
|| *decl_id == *super::graph::OBJECT_ID
355+
|| *decl_id == *super::graph::MODULE_ID
356+
|| *decl_id == *super::graph::CLASS_ID;
357+
!is_synthetic && self.has_no_definitions()
358+
}
359+
348360
pub fn add_definition(&mut self, definition_id: DefinitionId) {
349361
all_declarations!(self, it => {
350362
debug_assert!(

rust/rubydex/src/model/graph.rs

Lines changed: 145 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ pub struct Graph {
8787

8888
/// Paths to exclude from file discovery during indexing.
8989
excluded_paths: HashSet<PathBuf>,
90+
91+
/// Declarations that became empty during name cascade and may need removal.
92+
/// Deferred until after resolution — if the declaration was repopulated, skip it.
93+
/// Drained by `take_pending_declaration_cleanup()`.
94+
pending_declaration_cleanup: Vec<DeclarationId>,
9095
}
9196

9297
impl Graph {
@@ -129,6 +134,7 @@ impl Graph {
129134
name_dependents: IdentityHashMap::default(),
130135
pending_work: Vec::default(),
131136
excluded_paths: HashSet::new(),
137+
pending_declaration_cleanup: Vec::default(),
132138
}
133139
}
134140

@@ -628,6 +634,11 @@ impl Graph {
628634
std::mem::take(&mut self.pending_work)
629635
}
630636

637+
/// Drains declarations that need post-resolution cleanup.
638+
pub(crate) fn take_pending_declaration_cleanup(&mut self) -> Vec<DeclarationId> {
639+
std::mem::take(&mut self.pending_declaration_cleanup)
640+
}
641+
631642
fn push_work(&mut self, unit: Unit) {
632643
self.pending_work.push(unit);
633644
}
@@ -636,6 +647,28 @@ impl Graph {
636647
self.pending_work.extend(units);
637648
}
638649

650+
/// Post-resolution cleanup: removes declarations that were emptied during name
651+
/// cascade and never repopulated by re-resolution. Runs `invalidate_graph` on
652+
/// any that are still empty, which cascades to their members and singletons.
653+
/// Post-resolution cleanup: removes declarations that were emptied during
654+
/// invalidation cascade and never repopulated by re-resolution. Cascades
655+
/// to members, singletons, and descendants via `invalidate_graph`.
656+
pub(crate) fn cleanup_empty_declarations(&mut self, candidates: Vec<DeclarationId>) {
657+
let items: Vec<InvalidationItem> = candidates
658+
.into_iter()
659+
.filter(|decl_id| {
660+
self.declarations
661+
.get(decl_id)
662+
.is_some_and(Declaration::has_no_definitions)
663+
})
664+
.map(InvalidationItem::Declaration)
665+
.collect();
666+
667+
if !items.is_empty() {
668+
self.invalidate_graph(items, IdentityHashMap::default());
669+
}
670+
}
671+
639672
/// Converts a `Resolved` `NameRef` back to `Unresolved`, preserving the original `Name` data.
640673
/// Returns the `DeclarationId` it was previously resolved to, if any.
641674
fn unresolve_name(&mut self, name_id: NameId) -> Option<DeclarationId> {
@@ -1038,10 +1071,14 @@ impl Graph {
10381071
.collect();
10391072

10401073
if !missed_def_ids.is_empty() {
1041-
for declaration in self.declarations.values_mut() {
1074+
for (decl_id, declaration) in &mut self.declarations {
1075+
let had_definitions = !declaration.definitions().is_empty();
10421076
for def_id in &missed_def_ids {
10431077
declaration.remove_definition(def_id);
10441078
}
1079+
if had_definitions && declaration.has_no_backing_definitions(decl_id) {
1080+
self.pending_declaration_cleanup.push(*decl_id);
1081+
}
10451082
}
10461083
}
10471084

@@ -1056,8 +1093,11 @@ impl Graph {
10561093
}
10571094
}
10581095

1059-
/// Unified invalidation worklist. Processes declaration and name items in a single loop,
1096+
/// Invalidation worklist. Processes declaration, name, and reference items in a loop,
10601097
/// where processing one item can push new items back onto the queue.
1098+
///
1099+
/// `pending_detachments` maps declarations to definitions that need to be detached
1100+
/// before deciding whether to remove or update each declaration.
10611101
fn invalidate_graph(
10621102
&mut self,
10631103
items: Vec<InvalidationItem>,
@@ -1125,10 +1165,13 @@ impl Graph {
11251165
let Some(decl) = self.declarations.get(&decl_id) else {
11261166
return;
11271167
};
1128-
let should_remove = decl.has_no_definitions() || !self.declarations.contains_key(decl.owner_id());
1168+
let should_remove =
1169+
decl.has_no_backing_definitions(&decl_id) || !self.declarations.contains_key(decl.owner_id());
11291170

11301171
if should_remove {
1131-
// Queue members + singleton for removal
1172+
// Remove path: definitions came from a deleted/changed file and won't
1173+
// re-resolve to this declaration. Removal must be immediate so child
1174+
// declarations see a missing owner and cascade correctly.
11321175
if let Some(ns) = decl.as_namespace() {
11331176
if let Some(singleton_id) = ns.singleton_class() {
11341177
queue.push(InvalidationItem::Declaration(*singleton_id));
@@ -1236,12 +1279,18 @@ impl Graph {
12361279
decl.remove_definition(def_id);
12371280
}
12381281

1282+
// Defer cleanup: unlike the remove path in invalidate_declaration
1283+
// (where definitions are from a deleted file and won't return),
1284+
// this definition is from a surviving file — it was re-queued and
1285+
// will likely re-resolve to the same declaration. Removing now
1286+
// would destroy accumulated state (singleton, members) that can't
1287+
// be rebuilt. Post-resolution cleanup removes it if still empty.
12391288
if self
12401289
.declarations
12411290
.get(&old_decl_id)
1242-
.is_some_and(Declaration::has_no_definitions)
1291+
.is_some_and(|decl| decl.has_no_backing_definitions(&old_decl_id))
12431292
{
1244-
queue.push(InvalidationItem::Declaration(old_decl_id));
1293+
self.pending_declaration_cleanup.push(old_decl_id);
12451294
}
12461295
}
12471296
NameDependent::ChildName(_) | NameDependent::NestedName(_) => {}
@@ -3670,6 +3719,79 @@ mod incremental_resolution_tests {
36703719
assert_no_dangling_definitions(context.graph());
36713720
}
36723721

3722+
#[test]
3723+
fn singleton_class_survives_when_reopener_is_deleted() {
3724+
let mut context = GraphTest::new();
3725+
context.index_uri("file:///foo.rb", "class Foo; def self.bar; end; end");
3726+
context.index_uri("file:///reopener.rb", "class Foo; end");
3727+
context.resolve();
3728+
3729+
assert_declaration_exists!(context, "Foo");
3730+
assert_declaration_exists!(context, "Foo::<Foo>");
3731+
3732+
context.delete_uri("file:///reopener.rb");
3733+
context.resolve();
3734+
3735+
assert_declaration_exists!(context, "Foo");
3736+
assert_declaration_exists!(context, "Foo::<Foo>");
3737+
}
3738+
3739+
#[test]
3740+
fn singleton_survives_when_singleton_definition_deleted_but_caller_remains() {
3741+
let mut context = GraphTest::new();
3742+
context.index_uri("file:///foo.rb", "class Foo; end");
3743+
context.index_uri("file:///foo_singleton.rb", "class Foo; class << self; end; end");
3744+
context.index_uri("file:///whatever.rb", "Foo.new");
3745+
context.resolve();
3746+
3747+
assert_declaration_exists!(context, "Foo");
3748+
assert_declaration_exists!(context, "Foo::<Foo>");
3749+
3750+
// Remove the file with the explicit `class << self`. Foo::<Foo> should
3751+
// survive because Foo.new in whatever.rb created an Attached reference
3752+
// that also triggers singleton creation.
3753+
context.delete_uri("file:///foo_singleton.rb");
3754+
context.resolve();
3755+
3756+
assert_declaration_exists!(context, "Foo");
3757+
assert_declaration_exists!(context, "Foo::<Foo>");
3758+
}
3759+
3760+
#[test]
3761+
fn nested_class_inside_singleton_scope_survives_reopener_deletion() {
3762+
let mut context = GraphTest::new();
3763+
3764+
context.index_uri(
3765+
"file:///main.rb",
3766+
r"
3767+
module Outer
3768+
class << self
3769+
class Inner
3770+
def initialize; end
3771+
end
3772+
3773+
def run
3774+
Inner.new
3775+
end
3776+
end
3777+
end
3778+
",
3779+
);
3780+
context.index_uri("file:///reopener.rb", "module Outer; end");
3781+
context.resolve();
3782+
3783+
assert_declaration_exists!(context, "Outer");
3784+
assert_declaration_exists!(context, "Outer::<Outer>::Inner");
3785+
assert_declaration_exists!(context, "Outer::<Outer>::Inner::<Inner>");
3786+
3787+
context.delete_uri("file:///reopener.rb");
3788+
context.resolve();
3789+
3790+
assert_declaration_exists!(context, "Outer");
3791+
assert_declaration_exists!(context, "Outer::<Outer>::Inner");
3792+
assert_declaration_exists!(context, "Outer::<Outer>::Inner::<Inner>");
3793+
}
3794+
36733795
#[test]
36743796
fn singleton_class_preserved_after_delete_and_reindex() {
36753797
let mut context = GraphTest::new();
@@ -3692,27 +3814,29 @@ mod incremental_resolution_tests {
36923814
}
36933815

36943816
#[test]
3695-
fn singleton_recreated_when_reference_nested_in_compact_class() {
3817+
fn singleton_members_cleaned_up_after_file_deletion() {
36963818
let mut context = GraphTest::new();
3697-
3698-
context.index_uri("file:///parent.rb", "module Parent; end");
3699-
context.index_uri("file:///target.rb", "class Parent::Target; end");
3700-
context.index_uri("file:///caller.rb", "class Parent::Caller; Parent::Target.new; end");
3819+
context.index_uri(
3820+
"file:///a.rb",
3821+
"
3822+
class Foo
3823+
@x = 1
3824+
def self.bar; end
3825+
end
3826+
",
3827+
);
3828+
context.index_uri("file:///b.rb", "class Foo; end");
37013829
context.resolve();
37023830

3703-
assert_declaration_exists!(context, "Parent::Target");
3704-
assert_declaration_exists!(context, "Parent::Target::<Target>");
3831+
assert_declaration_exists!(context, "Foo::<Foo>#@x");
3832+
assert_declaration_exists!(context, "Foo::<Foo>#bar()");
37053833

3706-
context.delete_uri("file:///parent.rb");
3707-
context.delete_uri("file:///target.rb");
3708-
context.resolve();
3709-
3710-
context.index_uri("file:///parent.rb", "module Parent; end");
3711-
context.index_uri("file:///target.rb", "class Parent::Target; end");
3834+
context.delete_uri("file:///a.rb");
37123835
context.resolve();
37133836

3714-
assert_declaration_exists!(context, "Parent::Target");
3715-
assert_declaration_exists!(context, "Parent::Target::<Target>");
3837+
assert_declaration_exists!(context, "Foo");
3838+
assert_declaration_does_not_exist!(context, "Foo::<Foo>#@x");
3839+
assert_declaration_does_not_exist!(context, "Foo::<Foo>#bar()");
37163840
}
37173841

37183842
#[test]

rust/rubydex/src/resolution.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ impl<'a> Resolver<'a> {
124124
self.graph.extend_work(std::mem::take(&mut self.unit_queue));
125125

126126
self.handle_remaining_definitions(other_ids);
127+
128+
// Post-resolution cleanup: declarations emptied during name cascade
129+
// (unresolve_dependent_name) and never repopulated should be removed.
130+
let mut cleanup_candidates = self.graph.take_pending_declaration_cleanup();
131+
if !cleanup_candidates.is_empty() {
132+
cleanup_candidates.sort_unstable();
133+
cleanup_candidates.dedup();
134+
self.graph.cleanup_empty_declarations(cleanup_candidates);
135+
}
127136
}
128137

129138
/// Resolves a single constant against the graph. This method is not meant to be used by the resolution phase, but by

0 commit comments

Comments
 (0)