Skip to content

Commit 0d7390a

Browse files
committed
Preserve singleton definitions when receiver is missing
Two related fixes for singleton class invalidation: 1. `class << Foo` definitions were dropped if Foo was temporarily missing. `handle_constant_declaration` now emits `Retry(None)` instead of `Unresolved(None)` for singletons whose receiver can't be resolved, so the unit survives across resolve cycles. `resolve_lexical_owner` no longer falls back to the surrounding scope when the immediate nesting is a `SingletonClass` without a declaration; methods inside the block are re-queued via `push_work` until the singleton is reattached. 2. Singletons kept alive only by a constant reference (e.g. `Foo::<Foo>` materialized by `Foo.new`) were not collected when the reference's file was deleted. `invalidate()` only seeds from removed definitions, so `remove_document_data` now queues declarations that become removable after reference detachment.
1 parent bb51310 commit 0d7390a

2 files changed

Lines changed: 115 additions & 10 deletions

File tree

rust/rubydex/src/model/graph.rs

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ impl Graph {
634634
self.tracked_todos.insert(decl_id);
635635
}
636636

637-
fn push_work(&mut self, unit: Unit) {
637+
pub(crate) fn push_work(&mut self, unit: Unit) {
638638
self.pending_work.push(unit);
639639
}
640640

@@ -1042,21 +1042,34 @@ impl Graph {
10421042
}
10431043
}
10441044

1045+
// Detach references and queue any declaration that becomes removable as a
1046+
// result. `invalidate()` only seeds from removed definitions, so a singleton
1047+
// kept alive purely by a reference (e.g. `Foo::<Foo>` materialized by
1048+
// `Foo.new`) would otherwise linger after the reference's file is deleted.
1049+
let mut emptied_by_ref_removal: Vec<InvalidationItem> = Vec::new();
10451050
for ref_id in document.constant_references() {
10461051
if let Some(constant_ref) = self.constant_references.remove(ref_id) {
10471052
// Detach from target declaration. References unresolved during invalidation
10481053
// were already detached; this catches the rest.
1049-
if let Some(NameRef::Resolved(resolved)) = self.names.get(constant_ref.name_id())
1050-
&& let Some(declaration) = self.declarations.get_mut(resolved.declaration_id())
1051-
{
1052-
declaration.remove_constant_reference(ref_id);
1054+
if let Some(NameRef::Resolved(resolved)) = self.names.get(constant_ref.name_id()) {
1055+
let target_id = *resolved.declaration_id();
1056+
if let Some(declaration) = self.declarations.get_mut(&target_id) {
1057+
declaration.remove_constant_reference(ref_id);
1058+
if declaration.is_removable() {
1059+
emptied_by_ref_removal.push(InvalidationItem::Declaration(target_id));
1060+
}
1061+
}
10531062
}
10541063

10551064
self.remove_name_dependent(*constant_ref.name_id(), NameDependent::Reference(*ref_id));
10561065
self.untrack_name(*constant_ref.name_id());
10571066
}
10581067
}
10591068

1069+
if !emptied_by_ref_removal.is_empty() {
1070+
self.invalidate_graph(emptied_by_ref_removal, IdentityHashMap::default());
1071+
}
1072+
10601073
// Detach removed definitions from their declarations.
10611074
// Most definitions were already detached by invalidate_declaration via
10621075
// pending_detachments. Definitions not handled by pending_detachments are
@@ -4306,4 +4319,74 @@ mod incremental_resolution_tests {
43064319

43074320
assert_declaration_ids_match(&incremental, &fresh);
43084321
}
4322+
4323+
/// Delete the receiver of a `class << Foo` block, resolve, then re-add the
4324+
/// receiver. The singleton's definition unit must survive the gap so that
4325+
/// when `Foo` returns, `Foo::<Foo>` and any methods inside the block are
4326+
/// reattached. Regression for the case where dropping singleton TODO
4327+
/// creation also dropped the singleton's definition unit on
4328+
/// `Outcome::Unresolved(None)` — emitting `Retry(None)` instead keeps the
4329+
/// unit queued across resolve cycles.
4330+
#[test]
4331+
fn singleton_definition_survives_receiver_delete_readd() {
4332+
let mut incremental = GraphTest::new();
4333+
incremental.index_uri("file:///foo.rb", "class Foo; end");
4334+
incremental.index_uri("file:///singleton.rb", "class << Foo; def bar; end; end");
4335+
incremental.resolve();
4336+
assert_declaration_exists!(incremental, "Foo::<Foo>");
4337+
assert_declaration_exists!(incremental, "Foo::<Foo>#bar()");
4338+
4339+
incremental.delete_uri("file:///foo.rb");
4340+
incremental.resolve();
4341+
assert_declaration_does_not_exist!(incremental, "Foo");
4342+
assert_declaration_does_not_exist!(incremental, "Foo::<Foo>");
4343+
assert_declaration_does_not_exist!(incremental, "Foo::<Foo>#bar()");
4344+
// The method's lexical owner can't be resolved while the singleton is
4345+
// gone, so it must not get re-attached to `Object` as a fallback.
4346+
assert_declaration_does_not_exist!(incremental, "Object#bar()");
4347+
4348+
incremental.index_uri("file:///foo.rb", "class Foo; end");
4349+
incremental.resolve();
4350+
4351+
let mut fresh = GraphTest::new();
4352+
fresh.index_uri("file:///foo.rb", "class Foo; end");
4353+
fresh.index_uri("file:///singleton.rb", "class << Foo; def bar; end; end");
4354+
fresh.resolve();
4355+
4356+
assert_declaration_ids_match(&incremental, &fresh);
4357+
assert_declaration_exists!(incremental, "Foo::<Foo>#bar()");
4358+
}
4359+
4360+
/// A singleton class materialized solely by a constant reference (e.g.
4361+
/// `Foo.new` creating `Foo::<Foo>` even though no `class << Foo` block
4362+
/// exists) must be collected when its sole supporting reference is
4363+
/// removed. `invalidate()` only seeds from removed definitions, so the
4364+
/// reference detachment in `remove_document_data` queues now-removable
4365+
/// declarations for cleanup.
4366+
#[test]
4367+
fn singleton_kept_only_by_reference_collected_on_ref_delete() {
4368+
let mut context = GraphTest::new();
4369+
context.index_uri("file:///foo.rb", "class Foo; end");
4370+
context.index_uri("file:///user.rb", "Foo.new");
4371+
context.resolve();
4372+
assert_declaration_exists!(context, "Foo::<Foo>");
4373+
4374+
context.delete_uri("file:///user.rb");
4375+
context.resolve();
4376+
4377+
// The reference that materialized the singleton is gone, so the
4378+
// singleton itself must not linger and `Foo.singleton_class_id` must
4379+
// be cleared.
4380+
assert_declaration_does_not_exist!(context, "Foo::<Foo>");
4381+
let foo = context
4382+
.graph()
4383+
.declarations()
4384+
.get(&crate::model::ids::DeclarationId::from("Foo"))
4385+
.expect("Foo should still exist");
4386+
let foo_ns = foo.as_namespace().expect("Foo is a namespace");
4387+
assert!(
4388+
foo_ns.singleton_class().is_none(),
4389+
"Foo.singleton_class_id should be cleared after the singleton is removed"
4390+
);
4391+
}
43094392
} // mod incremental_resolution_tests

rust/rubydex/src/resolution.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,13 @@ impl<'a> Resolver<'a> {
301301
singleton_id
302302
}
303303
None => {
304+
// resolve_lexical_owner returns None when the immediate nesting is
305+
// a SingletonClass def whose declaration is missing (e.g. receiver
306+
// was just deleted). Re-queue so the next resolve, after the
307+
// singleton is reattached, can place the method on the right owner.
304308
let Some(resolved) = self.resolve_lexical_owner(*method_definition.lexical_nesting_id())
305309
else {
310+
self.graph.push_work(Unit::Definition(id));
306311
continue;
307312
};
308313
resolved
@@ -315,6 +320,7 @@ impl<'a> Resolver<'a> {
315320
}
316321
Definition::AttrAccessor(attr) => {
317322
let Some(owner_id) = self.resolve_lexical_owner(*attr.lexical_nesting_id()) else {
323+
self.graph.push_work(Unit::Definition(id));
318324
continue;
319325
};
320326

@@ -324,6 +330,7 @@ impl<'a> Resolver<'a> {
324330
}
325331
Definition::AttrReader(attr) => {
326332
let Some(owner_id) = self.resolve_lexical_owner(*attr.lexical_nesting_id()) else {
333+
self.graph.push_work(Unit::Definition(id));
327334
continue;
328335
};
329336

@@ -333,6 +340,7 @@ impl<'a> Resolver<'a> {
333340
}
334341
Definition::AttrWriter(attr) => {
335342
let Some(owner_id) = self.resolve_lexical_owner(*attr.lexical_nesting_id()) else {
343+
self.graph.push_work(Unit::Definition(id));
336344
continue;
337345
};
338346

@@ -612,8 +620,17 @@ impl<'a> Resolver<'a> {
612620
// If no declaration exists yet for this definition, walk up the lexical chain.
613621
// This handles the case where attr_* definitions inside methods are processed
614622
// before the method definition itself.
623+
//
624+
// Exception: a SingletonClass with no declaration (because its receiver was just
625+
// deleted) must not fall back to the surrounding scope. Doing so would attach
626+
// members like `def bar` to e.g. `Object` instead of waiting for the singleton
627+
// to be reattached on a later resolve. Return `None` so the caller can skip and
628+
// re-queue the definition.
615629
let Some(declaration_id) = self.graph.definition_id_to_declaration_id(id) else {
616630
let definition = self.graph.definitions().get(&id).unwrap();
631+
if matches!(definition, Definition::SingletonClass(_)) {
632+
return None;
633+
}
617634
return self.resolve_lexical_owner(*definition.lexical_nesting_id());
618635
};
619636

@@ -1035,11 +1052,16 @@ impl<'a> Resolver<'a> {
10351052
// placed; Todos get promoted when real definitions appear later.
10361053
//
10371054
// Singleton classes are the exception: `class << UndefinedReceiver` attaches via
1038-
// `set_singleton_class_id`, not `add_member`, so a TODO receiver would never gain
1039-
// a member and would remain empty forever. Skip TODO creation for singletons —
1040-
// the singleton simply can't be placed. Retry (preserved via `preserve_retry`) lets
1041-
// the unit cycle back through the worklist if the receiver might resolve later.
1042-
Outcome::Unresolved(None) if !singleton => Outcome::Resolved(self.create_todo_for_parent(name_id), None),
1055+
// `set_singleton_class_id`, not `add_member`, so a TODO receiver would never gain a
1056+
// member and would remain empty forever. Skip TODO creation and emit Retry instead
1057+
// of Unresolved so the unit is preserved across resolve cycles. `made_progress` only
1058+
// flips on Resolved, so retrying is safe — the inner fixpoint terminates and any
1059+
// leftover unit is drained back to `pending_work`. This matters for delete/re-add:
1060+
// after deleting `class Foo`, the `class << Foo; def bar; end` definition unit
1061+
// re-enters resolution with Foo missing; emitting Retry keeps it queued so the next
1062+
// resolve (with Foo restored) can attach `Foo::<Foo>#bar` again.
1063+
Outcome::Unresolved(None) if singleton => Outcome::Retry(None),
1064+
Outcome::Unresolved(None) => Outcome::Resolved(self.create_todo_for_parent(name_id), None),
10431065
other => other,
10441066
};
10451067

0 commit comments

Comments
 (0)