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