From 5b2cf840928248ab3200ca920f3f0a53c5ed67cb Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Wed, 4 Mar 2026 17:36:08 -0500 Subject: [PATCH 1/4] Introduce TodoDeclaration --- ext/rubydex/declaration.c | 4 ++++ ext/rubydex/declaration.h | 1 + rust/rubydex-sys/src/declaration_api.rs | 2 ++ rust/rubydex/src/model/declaration.rs | 7 ++++++- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ext/rubydex/declaration.c b/ext/rubydex/declaration.c index 8cc129a3..09b105af 100644 --- a/ext/rubydex/declaration.c +++ b/ext/rubydex/declaration.c @@ -10,6 +10,7 @@ VALUE cNamespace; VALUE cClass; VALUE cModule; VALUE cSingletonClass; +VALUE cTodo; VALUE cConstant; VALUE cConstantAlias; VALUE cMethod; @@ -26,6 +27,8 @@ VALUE rdxi_declaration_class_for_kind(CDeclarationKind kind) { return cModule; case CDeclarationKind_SingletonClass: return cSingletonClass; + case CDeclarationKind_Todo: + return cTodo; case CDeclarationKind_Constant: return cConstant; case CDeclarationKind_ConstantAlias: @@ -297,6 +300,7 @@ void rdxi_initialize_declaration(VALUE mRubydex) { cClass = rb_define_class_under(mRubydex, "Class", cNamespace); cModule = rb_define_class_under(mRubydex, "Module", cNamespace); cSingletonClass = rb_define_class_under(mRubydex, "SingletonClass", cNamespace); + cTodo = rb_define_class_under(mRubydex, "Todo", cNamespace); cConstant = rb_define_class_under(mRubydex, "Constant", cDeclaration); cConstantAlias = rb_define_class_under(mRubydex, "ConstantAlias", cDeclaration); cMethod = rb_define_class_under(mRubydex, "Method", cDeclaration); diff --git a/ext/rubydex/declaration.h b/ext/rubydex/declaration.h index 40a51f10..06ad0369 100644 --- a/ext/rubydex/declaration.h +++ b/ext/rubydex/declaration.h @@ -9,6 +9,7 @@ extern VALUE cNamespace; extern VALUE cClass; extern VALUE cModule; extern VALUE cSingletonClass; +extern VALUE cTodo; extern VALUE cConstant; extern VALUE cConstantAlias; extern VALUE cMethod; diff --git a/rust/rubydex-sys/src/declaration_api.rs b/rust/rubydex-sys/src/declaration_api.rs index aab54339..176dd7b8 100644 --- a/rust/rubydex-sys/src/declaration_api.rs +++ b/rust/rubydex-sys/src/declaration_api.rs @@ -22,6 +22,7 @@ pub enum CDeclarationKind { GlobalVariable = 6, InstanceVariable = 7, ClassVariable = 8, + Todo = 9, } #[repr(C)] @@ -51,6 +52,7 @@ impl CDeclaration { Declaration::Namespace(Namespace::Class(_)) => CDeclarationKind::Class, Declaration::Namespace(Namespace::Module(_)) => CDeclarationKind::Module, Declaration::Namespace(Namespace::SingletonClass(_)) => CDeclarationKind::SingletonClass, + Declaration::Namespace(Namespace::Todo(_)) => CDeclarationKind::Todo, Declaration::Constant(_) => CDeclarationKind::Constant, Declaration::ConstantAlias(_) => CDeclarationKind::ConstantAlias, Declaration::Method(_) => CDeclarationKind::Method, diff --git a/rust/rubydex/src/model/declaration.rs b/rust/rubydex/src/model/declaration.rs index ebd76c0a..9bd99c9b 100644 --- a/rust/rubydex/src/model/declaration.rs +++ b/rust/rubydex/src/model/declaration.rs @@ -60,6 +60,7 @@ macro_rules! all_declarations { Declaration::Namespace(Namespace::Class($var)) => $expr, Declaration::Namespace(Namespace::Module($var)) => $expr, Declaration::Namespace(Namespace::SingletonClass($var)) => $expr, + Declaration::Namespace(Namespace::Todo($var)) => $expr, Declaration::Constant($var) => $expr, Declaration::ConstantAlias($var) => $expr, Declaration::Method($var) => $expr, @@ -76,6 +77,7 @@ macro_rules! all_namespaces { Namespace::Class($var) => $expr, Namespace::Module($var) => $expr, Namespace::SingletonClass($var) => $expr, + Namespace::Todo($var) => $expr, } }; } @@ -378,6 +380,7 @@ pub enum Namespace { Class(Box), SingletonClass(Box), Module(Box), + Todo(Box), } assert_mem_size!(Namespace, 16); @@ -388,6 +391,7 @@ impl Namespace { Namespace::Class(_) => "Class", Namespace::SingletonClass(_) => "SingletonClass", Namespace::Module(_) => "Module", + Namespace::Todo(_) => "", } } @@ -504,7 +508,8 @@ namespace_declaration!(Module, ModuleDeclaration); assert_mem_size!(ModuleDeclaration, 224); namespace_declaration!(SingletonClass, SingletonClassDeclaration); assert_mem_size!(SingletonClassDeclaration, 224); - +namespace_declaration!(Todo, TodoDeclaration); +assert_mem_size!(TodoDeclaration, 224); simple_declaration!(ConstantDeclaration); assert_mem_size!(ConstantDeclaration, 112); simple_declaration!(MethodDeclaration); From 8f72b8b45e2cf4c44bb1531794235d4443b5fb61 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Wed, 4 Mar 2026 17:38:41 -0500 Subject: [PATCH 2/4] Use Todo promotion to collapse resolution into a single pass --- rust/rubydex/src/model/graph.rs | 26 +++++++--- rust/rubydex/src/resolution.rs | 90 +++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/rust/rubydex/src/model/graph.rs b/rust/rubydex/src/model/graph.rs index c19713e2..3436ec72 100644 --- a/rust/rubydex/src/model/graph.rs +++ b/rust/rubydex/src/model/graph.rs @@ -84,14 +84,20 @@ impl Graph { { let declaration_id = DeclarationId::from(&fully_qualified_name); - let should_promote = self.declarations.get(&declaration_id).is_some_and(|existing| { - matches!(existing, Declaration::Constant(_)) - && matches!( - self.definitions.get(&definition_id), - Some(Definition::Class(_) | Definition::Module(_) | Definition::SingletonClass(_)) - ) - && self.all_definitions_promotable(existing) - }); + let is_namespace_definition = matches!( + self.definitions.get(&definition_id), + Some(Definition::Class(_) | Definition::Module(_) | Definition::SingletonClass(_)) + ); + + let should_promote = is_namespace_definition + && self + .declarations + .get(&declaration_id) + .is_some_and(|existing| match existing { + Declaration::Constant(_) => self.all_definitions_promotable(existing), + Declaration::Namespace(Namespace::Todo(_)) => true, + _ => false, + }); match self.declarations.entry(declaration_id) { Entry::Occupied(mut occupied_entry) => { @@ -633,6 +639,10 @@ impl Graph { Declaration::Namespace(Namespace::SingletonClass(it)) => { it.add_member(member_str_id, member_declaration_id); } + Declaration::Namespace(Namespace::Todo(it)) => it.add_member(member_str_id, member_declaration_id), + Declaration::Constant(_) => { + // TODO: temporary hack to avoid crashing on `Struct.new`, `Class.new` and `Module.new` + } _ => panic!("Tried to add member to a declaration that isn't a namespace"), } } diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index eb402238..aff20b40 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashSet, VecDeque}, + collections::{HashSet, VecDeque, hash_map::Entry}, hash::BuildHasher, }; @@ -7,7 +7,7 @@ use crate::model::{ declaration::{ Ancestor, Ancestors, ClassDeclaration, ClassVariableDeclaration, ConstantAliasDeclaration, ConstantDeclaration, Declaration, GlobalVariableDeclaration, InstanceVariableDeclaration, MethodDeclaration, ModuleDeclaration, - Namespace, SingletonClassDeclaration, + Namespace, SingletonClassDeclaration, TodoDeclaration, }, definitions::{Definition, Mixin, Receiver}, graph::{CLASS_ID, Graph, MODULE_ID, OBJECT_ID}, @@ -211,6 +211,7 @@ impl<'a> Resolver<'a> { } Outcome::Unresolved(None) => { // We couldn't resolve this name. Emit a diagnostic + self.unit_queue.push_back(unit_id); } Outcome::Retry(Some(id_needing_linearization)) | Outcome::Unresolved(Some(id_needing_linearization)) => { self.unit_queue.push_back(unit_id); @@ -1057,12 +1058,50 @@ impl<'a> Resolver<'a> { fn name_owner_id(&mut self, name_id: NameId) -> Outcome { let name_ref = self.graph.names().get(&name_id).unwrap(); - if let Some(parent_scope) = name_ref.parent_scope().as_ref() { + if let Some(&parent_scope) = name_ref.parent_scope().as_ref() { // If we have `A::B`, the owner of `B` is whatever `A` resolves to. // If `A` is an alias, resolve through to get the actual namespace. - match self.resolve_constant_internal(*parent_scope) { + // On `Retry`, we don't create a Todo: the parent may still resolve through inheritance once ancestors are + // linearized. We only create Todos for `Unresolved` outcomes where the parent is genuinely unknown. + match self.resolve_constant_internal(parent_scope) { Outcome::Resolved(id, linearization) => self.resolve_to_primary_namespace(id, linearization), - other => other, + // Retry or Unresolved(Some(_)) means we might find it later through ancestor linearization + Outcome::Retry(id) => Outcome::Retry(id), + Outcome::Unresolved(Some(id)) => Outcome::Unresolved(Some(id)), + // Only create a Todo when genuinely unresolvable (no pending linearizations) + Outcome::Unresolved(None) => { + let parent_name = self.graph.names().get(&parent_scope).unwrap(); + let parent_str_id = *parent_name.str(); + + let parent_owner_id = match self.name_owner_id(parent_scope) { + Outcome::Resolved(id, _) => id, + _ => *OBJECT_ID, + }; + + let fully_qualified_name = if parent_owner_id == *OBJECT_ID { + self.graph.strings().get(&parent_str_id).unwrap().to_string() + } else { + format!( + "{}::{}", + self.graph.declarations().get(&parent_owner_id).unwrap().name(), + self.graph.strings().get(&parent_str_id).unwrap().as_str() + ) + }; + + let declaration_id = DeclarationId::from(&fully_qualified_name); + + if let Entry::Vacant(e) = + self.graph.declarations_mut().entry(declaration_id) + { + e.insert(Declaration::Namespace(Namespace::Todo(Box::new(TodoDeclaration::new( + fully_qualified_name, + parent_owner_id, + ))))); + self.graph.add_member(&parent_owner_id, declaration_id, parent_str_id); + } + + Outcome::Resolved(declaration_id, None) + } } } else if let Some(nesting_id) = name_ref.nesting() && !name_ref.parent_scope().is_top_level() @@ -1680,8 +1719,8 @@ mod tests { assert_constant_reference_to, assert_declaration_definitions_count_eq, assert_declaration_does_not_exist, assert_declaration_exists, assert_declaration_kind_eq, assert_declaration_references_count_eq, assert_descendants, assert_diagnostics_eq, assert_instance_variables_eq, assert_members_eq, - assert_no_constant_alias_target, assert_no_diagnostics, assert_no_members, assert_owner_eq, - assert_singleton_class_eq, + assert_no_constant_alias_target, assert_no_diagnostics, assert_no_members, + assert_owner_eq, assert_singleton_class_eq, }; #[test] @@ -1916,9 +1955,12 @@ mod tests { assert_no_diagnostics!(&context); - assert_declaration_does_not_exist!(context, "Foo"); - assert_declaration_does_not_exist!(context, "Foo::Bar"); - assert_declaration_does_not_exist!(context, "Foo::Bar::Baz"); + assert_declaration_kind_eq!(context, "Foo", ""); + + assert_members_eq!(context, "Object", vec!["Foo"]); + assert_members_eq!(context, "Foo", vec!["Bar"]); + assert_members_eq!(context, "Foo::Bar", vec!["Baz"]); + assert_no_members!(context, "Foo::Bar::Baz"); } #[test] @@ -5261,4 +5303,32 @@ mod tests { assert_declaration_does_not_exist!(context, "Foo::"); assert_declaration_does_not_exist!(context, "Foo::#bar()"); } + + #[test] + fn resolve_missing_declaration_to_todo() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Foo::Bar + include Foo::Baz + + def bar; end + end + + module Foo::Baz + def baz; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_declaration_kind_eq!(context, "Foo", ""); + + assert_members_eq!(context, "Object", vec!["Foo"]); + assert_members_eq!(context, "Foo", vec!["Bar", "Baz"]); + assert_members_eq!(context, "Foo::Bar", vec!["bar()"]); + assert_members_eq!(context, "Foo::Baz", vec!["baz()"]); + } } From e817676f1e107decc4157f543008444c899a0e76 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Wed, 4 Mar 2026 17:39:29 -0500 Subject: [PATCH 3/4] Add resolution tests for Todo declarations and promotion --- rust/rubydex/src/resolution.rs | 90 ++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index aff20b40..d7c865e4 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashSet, VecDeque, hash_map::Entry}, + collections::{HashSet, VecDeque}, hash::BuildHasher, }; @@ -211,7 +211,6 @@ impl<'a> Resolver<'a> { } Outcome::Unresolved(None) => { // We couldn't resolve this name. Emit a diagnostic - self.unit_queue.push_back(unit_id); } Outcome::Retry(Some(id_needing_linearization)) | Outcome::Unresolved(Some(id_needing_linearization)) => { self.unit_queue.push_back(unit_id); @@ -1090,7 +1089,7 @@ impl<'a> Resolver<'a> { let declaration_id = DeclarationId::from(&fully_qualified_name); - if let Entry::Vacant(e) = + if let std::collections::hash_map::Entry::Vacant(e) = self.graph.declarations_mut().entry(declaration_id) { e.insert(Declaration::Namespace(Namespace::Todo(Box::new(TodoDeclaration::new( @@ -5331,4 +5330,89 @@ mod tests { assert_members_eq!(context, "Foo::Bar", vec!["bar()"]); assert_members_eq!(context, "Foo::Baz", vec!["baz()"]); } + + #[test] + fn todo_declaration_promoted_to_real_namespace() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Foo::Bar + def bar; end + end + + class Foo + def foo; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Foo was initially created as a Todo (from class Foo::Bar), then promoted to Class + assert_declaration_kind_eq!(context, "Foo", "Class"); + + assert_members_eq!(context, "Object", vec!["Foo"]); + assert_members_eq!(context, "Foo", vec!["Bar", "foo()"]); + assert_members_eq!(context, "Foo::Bar", vec!["bar()"]); + } + + #[test] + fn todo_declaration_promoted_to_real_namespace_incrementally() { + let mut context = GraphTest::new(); + context.index_uri("file:///bar.rb", { + r" + class Foo::Bar + def bar; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_declaration_kind_eq!(context, "Foo", ""); + + context.index_uri("file:///foo.rb", { + r" + class Foo + def foo; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Foo was promoted from Todo to Class after the second resolution + assert_declaration_kind_eq!(context, "Foo", "Class"); + + assert_members_eq!(context, "Object", vec!["Foo"]); + assert_members_eq!(context, "Foo", vec!["Bar", "foo()"]); + assert_members_eq!(context, "Foo::Bar", vec!["bar()"]); + } + + #[test] + fn qualified_name_inside_nesting_resolves_to_top_level() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + module Foo + class Bar::Baz + def qux; end + end + end + + module Bar + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_declaration_kind_eq!(context, "Bar", "Module"); + assert_members_eq!(context, "Bar", vec!["Baz"]); + assert_declaration_exists!(context, "Bar::Baz"); + assert_members_eq!(context, "Bar::Baz", vec!["qux()"]); + assert_declaration_does_not_exist!(context, "Foo::Bar"); + } } From f7e4c2f34b610d73f08aad6d82e1b2be1f6da25d Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Wed, 4 Mar 2026 18:32:19 -0500 Subject: [PATCH 4/4] Fix Todo placement for bare names inside lexical nesting When resolving `class Bar::Baz` inside `module Foo`, if `Bar` can't be found, the previous code used lexical nesting to create a Todo named `"Foo::Bar"`. This was wrong: since `Bar` has no explicit `::` prefix, the placeholder should live at the top level as `"Bar"`, so it can be promoted when `module Bar` is later discovered. Fix: in the Unresolved(None) arm of name_owner_id, check whether the failing scope has an explicit `::` prefix. If not (bare name), use OBJECT_ID directly instead of recursing through nesting. For qualified names like `Outer::Inner` the recursive call is still correct. The existing Todo promotion mechanism in add_declaration handles the rest: when `module Bar` is processed (same or next resolve_all), the Todo at `"Bar"` is promoted and its members transferred. --- rust/rubydex/src/resolution.rs | 56 ++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index d7c865e4..d7f39732 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -1071,10 +1071,20 @@ impl<'a> Resolver<'a> { Outcome::Unresolved(None) => { let parent_name = self.graph.names().get(&parent_scope).unwrap(); let parent_str_id = *parent_name.str(); - - let parent_owner_id = match self.name_owner_id(parent_scope) { - Outcome::Resolved(id, _) => id, - _ => *OBJECT_ID, + let parent_has_explicit_prefix = parent_name.parent_scope().as_ref().is_some(); + // NLL: borrow of parent_name ends here + + // For bare names (no explicit `::` prefix), always use OBJECT_ID as the owner. + // Using nesting here would create "Nesting::Bar" instead of "Bar" for a bare `Bar` + // reference, which is incorrect: if `Bar` can't be found anywhere, the placeholder + // should live at the top level so it can be promoted when `module Bar` appears later. + let parent_owner_id = if parent_has_explicit_prefix { + match self.name_owner_id(parent_scope) { + Outcome::Resolved(id, _) => id, + _ => *OBJECT_ID, + } + } else { + *OBJECT_ID }; let fully_qualified_name = if parent_owner_id == *OBJECT_ID { @@ -1718,8 +1728,8 @@ mod tests { assert_constant_reference_to, assert_declaration_definitions_count_eq, assert_declaration_does_not_exist, assert_declaration_exists, assert_declaration_kind_eq, assert_declaration_references_count_eq, assert_descendants, assert_diagnostics_eq, assert_instance_variables_eq, assert_members_eq, - assert_no_constant_alias_target, assert_no_diagnostics, assert_no_members, - assert_owner_eq, assert_singleton_class_eq, + assert_no_constant_alias_target, assert_no_diagnostics, assert_no_members, assert_owner_eq, + assert_singleton_class_eq, }; #[test] @@ -5415,4 +5425,38 @@ mod tests { assert_members_eq!(context, "Bar::Baz", vec!["qux()"]); assert_declaration_does_not_exist!(context, "Foo::Bar"); } + + #[test] + fn qualified_name_inside_nesting_resolves_when_discovered_incrementally() { + let mut context = GraphTest::new(); + context.index_uri("file:///baz.rb", { + r" + module Foo + class Bar::Baz + def qux; end + end + end + " + }); + context.resolve(); + + // Bar is unknown — a Todo is created at the top level, not "Foo::Bar" + assert_declaration_kind_eq!(context, "Bar", ""); + assert_declaration_does_not_exist!(context, "Foo::Bar"); + + context.index_uri("file:///bar.rb", { + r" + module Bar + end + " + }); + context.resolve(); + + // After discovering top-level Bar, the Todo should be promoted and Baz re-homed. + assert_no_diagnostics!(&context); + assert_declaration_kind_eq!(context, "Bar", "Module"); + assert_members_eq!(context, "Bar", vec!["Baz"]); + assert_members_eq!(context, "Bar::Baz", vec!["qux()"]); + assert_declaration_does_not_exist!(context, "Foo::Bar"); + } }