@@ -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
0 commit comments