Skip to content

Commit 5dc2e67

Browse files
committed
Refactor document data cleanup
1 parent 915b9af commit 5dc2e67

3 files changed

Lines changed: 158 additions & 40 deletions

File tree

rust/rubydex/src/model/declaration.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,10 +357,8 @@ impl Declaration {
357357
all_declarations!(self, it => it.definition_ids.is_empty())
358358
}
359359

360-
/// Returns true if this declaration is no longer anchored to anything
361-
/// and can be removed. For most declarations, that means having no
362-
/// definitions. Singleton classes are also kept alive by members and
363-
/// inbound constant references.
360+
/// Returns true if this declaration has no local anchors and can be removed.
361+
/// Graph-level cleanup can still keep declarations alive based on external anchors.
364362
#[must_use]
365363
pub fn is_removable(&self) -> bool {
366364
if let Some(ns) = self.as_singleton_class() {

rust/rubydex/src/model/definitions.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,18 @@ impl Definition {
179179
}
180180
}
181181

182+
#[must_use]
183+
pub fn has_extend_mixin(&self) -> bool {
184+
let mixins = match self {
185+
Definition::Class(definition) => definition.mixins(),
186+
Definition::SingletonClass(definition) => definition.mixins(),
187+
Definition::Module(definition) => definition.mixins(),
188+
_ => return false,
189+
};
190+
191+
mixins.iter().any(|mixin| matches!(mixin, Mixin::Extend(_)))
192+
}
193+
182194
#[must_use]
183195
pub fn is_deprecated(&self) -> bool {
184196
all_definitions!(self, it => it.flags().is_deprecated())

rust/rubydex/src/model/graph.rs

Lines changed: 144 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,69 +1017,115 @@ impl Graph {
10171017
}
10181018

10191019
/// Removes raw document data (refs, defs, names, strings) from maps.
1020-
/// Does not touch declarations or perform invalidation -- that is handled by `invalidate`.
1020+
///
1021+
/// `invalidate` has already handled dependency invalidation and the normal
1022+
/// definition detachment path. This cleanup removes the raw map entries and
1023+
/// prunes declarations that become removable only after those entries are detached.
10211024
fn remove_document_data(&mut self, document: &Document) {
1025+
self.remove_document_method_references(document);
1026+
1027+
let mut declarations_to_prune = self.remove_document_constant_references(document);
1028+
declarations_to_prune.extend(self.detach_unmapped_document_definitions(document));
1029+
self.prune_removable_declarations(declarations_to_prune);
1030+
1031+
self.remove_document_definitions(document);
1032+
}
1033+
1034+
fn remove_document_method_references(&mut self, document: &Document) {
10221035
for ref_id in document.method_references() {
10231036
if let Some(method_ref) = self.method_references.remove(ref_id) {
10241037
self.untrack_string(*method_ref.str());
10251038
}
10261039
}
1040+
}
10271041

1028-
let mut emptied_by_ref_removal: Vec<InvalidationItem> = Vec::new();
1042+
fn remove_document_constant_references(&mut self, document: &Document) -> Vec<DeclarationId> {
1043+
let mut declarations_to_prune = Vec::new();
10291044
for ref_id in document.constant_references() {
10301045
if let Some(constant_ref) = self.constant_references.remove(ref_id) {
10311046
// Detach from target declaration. References unresolved during invalidation
10321047
// were already detached; this catches the rest.
1033-
if let Some(NameRef::Resolved(resolved)) = self.names.get(constant_ref.name_id()) {
1034-
let target_id = *resolved.declaration_id();
1035-
if let Some(declaration) = self.declarations.get_mut(&target_id) {
1036-
declaration.remove_constant_reference(ref_id);
1037-
if declaration.is_removable() {
1038-
emptied_by_ref_removal.push(InvalidationItem::Declaration(target_id));
1039-
}
1040-
}
1048+
if let Some(target_id) =
1049+
self.detach_constant_reference_from_resolved_target(*constant_ref.name_id(), *ref_id)
1050+
{
1051+
declarations_to_prune.push(target_id);
10411052
}
10421053

10431054
self.remove_name_dependent(*constant_ref.name_id(), NameDependent::Reference(*ref_id));
10441055
self.untrack_name(*constant_ref.name_id());
10451056
}
10461057
}
10471058

1048-
if !emptied_by_ref_removal.is_empty() {
1049-
self.invalidate_graph(emptied_by_ref_removal, IdentityHashMap::default());
1059+
declarations_to_prune
1060+
}
1061+
1062+
fn detach_constant_reference_from_resolved_target(
1063+
&mut self,
1064+
name_id: NameId,
1065+
ref_id: ConstantReferenceId,
1066+
) -> Option<DeclarationId> {
1067+
let target_id = match self.names.get(&name_id) {
1068+
Some(NameRef::Resolved(resolved)) => *resolved.declaration_id(),
1069+
Some(NameRef::Unresolved(_)) | None => return None,
1070+
};
1071+
1072+
let declaration = self.declarations.get_mut(&target_id)?;
1073+
declaration.remove_constant_reference(&ref_id);
1074+
self.declaration_is_removable(target_id).then_some(target_id)
1075+
}
1076+
1077+
fn detach_unmapped_document_definitions(&mut self, document: &Document) -> Vec<DeclarationId> {
1078+
let unmapped_definition_ids = self.unmapped_document_definition_ids(document);
1079+
1080+
if unmapped_definition_ids.is_empty() {
1081+
return Vec::new();
10501082
}
10511083

1052-
// Detach removed definitions from their declarations.
1053-
// Most definitions were already detached by invalidate_declaration via
1054-
// pending_detachments. Definitions not handled by pending_detachments are
1055-
// those where definition_to_declaration_id returns None, for example:
1056-
// - methods inside `class << self` when <Foo> was unresolved by a prior deletion
1057-
// - instance variables in class body (owned by singleton, but lookup resolves to class)
1058-
// - definitions whose enclosing namespace name chain is broken
1059-
// Detach those by scanning declarations for the remainder.
1060-
let missed_def_ids: Vec<DefinitionId> = document
1084+
self.detach_definitions_from_any_declaration(&unmapped_definition_ids)
1085+
}
1086+
1087+
/// Definitions that cannot be mapped through `definition_id_to_declaration_id`
1088+
/// are skipped by the normal invalidation detachment path. That happens when
1089+
/// the definition's name chain is already broken, or when the resolved owner
1090+
/// differs from the lexical owner used by lookup, such as singleton-owned
1091+
/// methods and instance variables.
1092+
fn unmapped_document_definition_ids(&self, document: &Document) -> Vec<DefinitionId> {
1093+
document
10611094
.definitions()
10621095
.iter()
10631096
.copied()
10641097
.filter(|def_id| self.definition_id_to_declaration_id(*def_id).is_none())
1065-
.collect();
1098+
.collect()
1099+
}
10661100

1067-
if !missed_def_ids.is_empty() {
1068-
let mut emptied: Vec<InvalidationItem> = Vec::new();
1069-
for (decl_id, declaration) in &mut self.declarations {
1070-
let had_definitions = !declaration.definitions().is_empty();
1071-
for def_id in &missed_def_ids {
1072-
declaration.remove_definition(def_id);
1073-
}
1074-
if had_definitions && declaration.is_removable() {
1075-
emptied.push(InvalidationItem::Declaration(*decl_id));
1076-
}
1101+
fn detach_definitions_from_any_declaration(
1102+
&mut self,
1103+
document_definition_ids: &[DefinitionId],
1104+
) -> Vec<DeclarationId> {
1105+
let mut touched_declarations = Vec::new();
1106+
1107+
for (decl_id, declaration) in &mut self.declarations {
1108+
let mut removed_document_definition = false;
1109+
for def_id in document_definition_ids {
1110+
removed_document_definition |= declaration.remove_definition(def_id);
10771111
}
1078-
if !emptied.is_empty() {
1079-
self.invalidate_graph(emptied, IdentityHashMap::default());
1112+
1113+
// This is a fallback scan across every declaration. Only prune
1114+
// declarations that actually contained one of this document's
1115+
// definitions; otherwise unrelated already-removable declarations
1116+
// can be removed while pending resolution work still references them.
1117+
if removed_document_definition {
1118+
touched_declarations.push(*decl_id);
10801119
}
10811120
}
10821121

1122+
touched_declarations
1123+
.into_iter()
1124+
.filter(|decl_id| self.declaration_is_removable(*decl_id))
1125+
.collect()
1126+
}
1127+
1128+
fn remove_document_definitions(&mut self, document: &Document) {
10831129
for def_id in document.definitions() {
10841130
let definition = self.definitions.remove(def_id).unwrap();
10851131

@@ -1091,6 +1137,52 @@ impl Graph {
10911137
}
10921138
}
10931139

1140+
fn prune_removable_declarations(&mut self, candidate_declaration_ids: Vec<DeclarationId>) {
1141+
if candidate_declaration_ids.is_empty() {
1142+
return;
1143+
}
1144+
1145+
let prune_items: Vec<_> = candidate_declaration_ids
1146+
.into_iter()
1147+
.filter(|id| self.declaration_is_removable(*id))
1148+
.map(InvalidationItem::Declaration)
1149+
.collect();
1150+
1151+
if prune_items.is_empty() {
1152+
return;
1153+
}
1154+
1155+
self.invalidate_graph(prune_items, IdentityHashMap::default());
1156+
}
1157+
1158+
fn declaration_is_removable(&self, declaration_id: DeclarationId) -> bool {
1159+
let Some(declaration) = self.declarations.get(&declaration_id) else {
1160+
return false;
1161+
};
1162+
1163+
if let Some(singleton) = declaration.as_singleton_class()
1164+
&& self.singleton_class_needed_for_extend(singleton)
1165+
{
1166+
return false;
1167+
}
1168+
1169+
declaration.is_removable()
1170+
}
1171+
1172+
fn singleton_class_needed_for_extend(&self, singleton: &Namespace) -> bool {
1173+
// `class Foo; extend Bar; end` records the extend on Foo's definition,
1174+
// but resolution installs Bar in Foo::<Foo>'s ancestor chain.
1175+
let Some(attached_object) = self.declarations.get(singleton.owner_id()) else {
1176+
return false;
1177+
};
1178+
1179+
attached_object
1180+
.definitions()
1181+
.iter()
1182+
.filter_map(|definition_id| self.definitions.get(definition_id))
1183+
.any(Definition::has_extend_mixin)
1184+
}
1185+
10941186
/// Unified invalidation worklist. Processes declaration and name items in a single loop,
10951187
/// where processing one item can push new items back onto the queue.
10961188
fn invalidate_graph(
@@ -1160,7 +1252,7 @@ impl Graph {
11601252
let Some(decl) = self.declarations.get(&decl_id) else {
11611253
return;
11621254
};
1163-
let should_remove = decl.is_removable() || !self.declarations.contains_key(decl.owner_id());
1255+
let should_remove = self.declaration_is_removable(decl_id) || !self.declarations.contains_key(decl.owner_id());
11641256

11651257
if should_remove {
11661258
// Queue members + singleton for removal
@@ -1213,7 +1305,7 @@ impl Graph {
12131305
}
12141306
}
12151307

1216-
if self.declarations.get(&owner_id).is_some_and(Declaration::is_removable) {
1308+
if self.declaration_is_removable(owner_id) {
12171309
queue.push(InvalidationItem::Declaration(owner_id));
12181310
}
12191311
}
@@ -3894,6 +3986,22 @@ mod incremental_resolution_tests {
38943986
);
38953987
}
38963988

3989+
#[test]
3990+
fn deleting_singleton_method_preserves_singleton_class_needed_by_extend() {
3991+
let mut context = GraphTest::new();
3992+
context.index_uri("file:///bar.rb", "module Bar; end");
3993+
context.index_uri("file:///foo.rb", "class Foo; extend Bar; end");
3994+
context.index_uri("file:///method.rb", "def Foo.baz; end");
3995+
context.resolve();
3996+
3997+
assert_declaration_exists!(context, "Foo::<Foo>");
3998+
3999+
context.delete_uri("file:///method.rb");
4000+
context.resolve();
4001+
4002+
assert_declaration_exists!(context, "Foo::<Foo>");
4003+
}
4004+
38974005
#[test]
38984006
fn no_duplicate_definition_on_identical_file_delete_readd() {
38994007
let source = "class Foo; def self.run; end; def run; end; end";

0 commit comments

Comments
 (0)