From 190c8d8f227febf9d0c3927728fd646d81420129 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 25 Jun 2026 16:21:16 +0900 Subject: [PATCH 1/4] Add recursive constant resolution Assisted-By: devx/7c3d70e1-327c-4158-8ab5-ffccba197089 --- rust/rubydex/src/resolution.rs | 34 ++++++++ rust/rubydex/src/resolution_tests.rs | 113 ++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index 1070a914b..ef77c3508 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -145,6 +145,40 @@ impl<'a> Resolver<'a> { } } + /// Resolves a single constant and the names it depends on against the graph. This is meant for APIs that create a + /// temporary name chain outside of the normal resolution phase. + pub fn resolve_constant_rec(&mut self, name_id: NameId) -> Option { + self.resolve_constant_rec_internal(name_id, &mut HashSet::new()) + } + + fn resolve_constant_rec_internal( + &mut self, + name_id: NameId, + resolving: &mut HashSet, + ) -> Option { + if !resolving.insert(name_id) { + return self.graph.name_id_to_declaration_id(name_id).copied(); + } + + let Some(name_ref) = self.graph.names().get(&name_id).cloned() else { + resolving.remove(&name_id); + return None; + }; + + if let Some(nesting_id) = name_ref.nesting() { + self.resolve_constant_rec_internal(*nesting_id, resolving); + } + + if let Some(parent_scope_id) = name_ref.parent_scope().as_ref() { + self.resolve_constant_rec_internal(*parent_scope_id, resolving); + } + + let result = self.resolve_constant(name_id); + resolving.remove(&name_id); + + result + } + /// Handles a unit of work for resolving a constant definition or singleton method fn handle_definition_unit(&mut self, unit_id: Unit, id: DefinitionId) { let mut needs_linearization = false; diff --git a/rust/rubydex/src/resolution_tests.rs b/rust/rubydex/src/resolution_tests.rs index f320eea4a..ea3b2d1f5 100644 --- a/rust/rubydex/src/resolution_tests.rs +++ b/rust/rubydex/src/resolution_tests.rs @@ -9,7 +9,11 @@ use crate::{ 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, diagnostic::Rule, - model::{declaration::Ancestors, ids::DeclarationId, name::NameRef}, + model::{ + declaration::Ancestors, + ids::{DeclarationId, StringId}, + name::{Name, NameRef, ParentScope}, + }, resolution::Resolver, test_utils::GraphTest, }; @@ -21,6 +25,113 @@ fn graph_test() -> GraphTest { mod constant_resolution_tests { use super::*; + #[test] + fn recursive_constant_resolution_resolves_parent_scope_chain_first() { + let mut context = graph_test(); + context.index_uri("file:///foo.rb", { + r" + module Foo + module Bar + CONST = 1 + end + end + " + }); + context.resolve(); + + // Equivalent to resolving: + // + // ::Foo::Bar::CONST + let mut graph = context.into_graph(); + let foo_id = graph.add_name(Name::new(StringId::from("Foo"), ParentScope::TopLevel, None)); + let bar_id = graph.add_name(Name::new(StringId::from("Bar"), ParentScope::Some(foo_id), None)); + let const_id = graph.add_name(Name::new(StringId::from("CONST"), ParentScope::Some(bar_id), None)); + + let mut resolver = Resolver::new(&mut graph); + + assert_eq!(None, resolver.resolve_constant(const_id)); + assert_eq!( + Some(DeclarationId::from("Foo::Bar::CONST")), + resolver.resolve_constant_rec(const_id) + ); + } + + #[test] + fn recursive_constant_resolution_resolves_nesting_names_first() { + let mut context = graph_test(); + context.index_uri("file:///foo.rb", { + r" + module Foo + module Bar + CONST = 1 + end + end + " + }); + context.resolve(); + + // Equivalent to resolving `CONST` from: + // + // module ::Foo + // module ::Foo::Bar + // CONST + // end + // end + let mut graph = context.into_graph(); + let outer_foo_id = graph.add_name(Name::new(StringId::from("Foo"), ParentScope::TopLevel, None)); + let inner_parent_foo_id = graph.add_name(Name::new( + StringId::from("Foo"), + ParentScope::TopLevel, + Some(outer_foo_id), + )); + let inner_bar_id = graph.add_name(Name::new( + StringId::from("Bar"), + ParentScope::Some(inner_parent_foo_id), + Some(outer_foo_id), + )); + let const_id = graph.add_name(Name::new( + StringId::from("CONST"), + ParentScope::None, + Some(inner_bar_id), + )); + + let mut resolver = Resolver::new(&mut graph); + + assert_eq!(None, resolver.resolve_constant(const_id)); + assert_eq!( + Some(DeclarationId::from("Foo::Bar::CONST")), + resolver.resolve_constant_rec(const_id) + ); + } + + #[test] + fn recursive_constant_resolution_resolves_attached_parent_scope_names_first() { + let mut context = graph_test(); + context.index_uri("file:///foo.rb", { + r" + class Foo; end + " + }); + context.resolve(); + + // Equivalent to resolving `self` from: + // + // class << ::Foo + // self # equivalent to ::Foo:: + // end + let mut graph = context.into_graph(); + let foo_id = graph.add_name(Name::new(StringId::from("Foo"), ParentScope::TopLevel, None)); + let singleton_id = graph.add_name(Name::new(StringId::from(""), ParentScope::Attached(foo_id), None)); + + let mut resolver = Resolver::new(&mut graph); + + assert_eq!(None, resolver.resolve_constant(singleton_id)); + assert_eq!( + Some(DeclarationId::from("Foo::")), + resolver.resolve_constant_rec(singleton_id) + ); + } + #[test] fn resolving_top_level_references() { let mut context = graph_test(); From 9d089bd80c89662c41097bbe66456abccaf5556e Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 25 Jun 2026 17:42:50 +0900 Subject: [PATCH 2/4] Add rooted nesting conversion coverage Assisted-By: devx/7c3d70e1-327c-4158-8ab5-ffccba197089 --- rust/rubydex-sys/src/name_api.rs | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/rust/rubydex-sys/src/name_api.rs b/rust/rubydex-sys/src/name_api.rs index d956ae0b2..790375209 100644 --- a/rust/rubydex-sys/src/name_api.rs +++ b/rust/rubydex-sys/src/name_api.rs @@ -186,4 +186,39 @@ mod tests { assert!(foo_name.parent_scope().is_none()); assert!(foo_name.nesting().is_none()); } + + #[test] + fn rooted_fully_qualified_nesting_is_converted_to_name_id() { + let mut graph = Graph::new(); + + let (name_id, _) = + nesting_stack_to_name_id(&mut graph, "CONST", vec!["::Foo".into(), "::Foo::Bar".into()]).unwrap(); + + let const_name = graph.names().get(&name_id).unwrap(); + assert_eq!(StringId::from("CONST"), *const_name.str()); + assert!(const_name.parent_scope().is_none()); + + // Equivalent to resolving `CONST` from: + // + // module ::Foo + // module ::Foo::Bar + // CONST + // end + // end + let bar_name = graph.names().get(&const_name.nesting().unwrap()).unwrap(); + assert_eq!(StringId::from("Bar"), *bar_name.str()); + + let bar_parent_foo_name = graph + .names() + .get(&bar_name.parent_scope().expect("Parent scope should exist")) + .unwrap(); + assert_eq!(StringId::from("Foo"), *bar_parent_foo_name.str()); + assert!(bar_parent_foo_name.parent_scope().is_top_level()); + assert_eq!(bar_name.nesting(), bar_parent_foo_name.nesting()); + + let outer_foo_name = graph.names().get(&bar_name.nesting().unwrap()).unwrap(); + assert_eq!(StringId::from("Foo"), *outer_foo_name.str()); + assert!(outer_foo_name.parent_scope().is_top_level()); + assert!(outer_foo_name.nesting().is_none()); + } } From 163bdb3ad61ba9aa90732c7daf08058c363c504f Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 25 Jun 2026 17:43:13 +0900 Subject: [PATCH 3/4] Use recursive resolution for Ruby constants Assisted-By: devx/7c3d70e1-327c-4158-8ab5-ffccba197089 --- rust/rubydex-sys/src/graph_api.rs | 2 +- test/graph_test.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/rust/rubydex-sys/src/graph_api.rs b/rust/rubydex-sys/src/graph_api.rs index 562963f7b..c8c7a2d05 100644 --- a/rust/rubydex-sys/src/graph_api.rs +++ b/rust/rubydex-sys/src/graph_api.rs @@ -141,7 +141,7 @@ pub unsafe extern "C" fn rdx_graph_resolve_constant( let mut resolver = Resolver::new(graph); - let declaration = match resolver.resolve_constant(name_id) { + let declaration = match resolver.resolve_constant_rec(name_id) { Some(id) => { let decl = graph.declarations().get(&id).unwrap(); Box::into_raw(Box::new(CDeclaration::from_declaration(id, decl))).cast_const() diff --git a/test/graph_test.rb b/test/graph_test.rb index 5a73a5958..cf32abfb6 100644 --- a/test/graph_test.rb +++ b/test/graph_test.rb @@ -281,6 +281,37 @@ class Bar::Baz end end + def test_graph_resolve_constant_with_top_level_nesting_frame + with_context do |context| + context.write!("foo.rb", <<~RUBY) + module Foo + module Bar + CONST = :foo_bar + end + end + + module Bar + CONST = :bar + end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + # Equivalent to resolving from: + # + # module Foo + # module ::Bar + # CONST + # end + # end + const = graph.resolve_constant("CONST", ["Foo", "::Bar"]) + + assert_equal("Bar::CONST", const.name) + end + end + def test_graph_resolve_with_invalid_argument graph = Rubydex::Graph.new From 3e92550be66a6cf21b07ea4f0e95910298de7c1e Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 25 Jun 2026 17:44:01 +0900 Subject: [PATCH 4/4] Expose definition source names Assisted-By: devx/7c3d70e1-327c-4158-8ab5-ffccba197089 --- ext/rubydex/definition.c | 18 ++++++++ rust/rubydex-sys/src/definition_api.rs | 62 +++++++++++++++++++++++++- test/definition_test.rb | 39 ++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/ext/rubydex/definition.c b/ext/rubydex/definition.c index 12938ecf8..491325282 100644 --- a/ext/rubydex/definition.c +++ b/ext/rubydex/definition.c @@ -141,6 +141,20 @@ static VALUE rdxr_definition_name(VALUE self) { return str; } +// Class/module and constant definition #source_name -> String +static VALUE rdxr_definition_source_name(VALUE self) { + HandleData *data; + TypedData_Get_Struct(self, HandleData, &handle_type, data); + + void *graph; + TypedData_Get_Struct(data->graph_obj, void *, &graph_type, graph); + + const char *name = rdx_definition_source_name(graph, data->id); + VALUE str = rb_utf8_str_new_cstr(name); + free_c_string(name); + return str; +} + // Definition#deprecated? -> bool static VALUE rdxr_definition_deprecated(VALUE self) { HandleData *data; @@ -394,6 +408,7 @@ void rdxi_initialize_definition(VALUE mod) { rb_define_method(cDefinition, "lexical_nesting", rdxr_definition_lexical_nesting, 0); cClassDefinition = rb_define_class_under(mRubydex, "ClassDefinition", cDefinition); + rb_define_method(cClassDefinition, "source_name", rdxr_definition_source_name, 0); rb_define_method(cClassDefinition, "superclass", rdxr_class_definition_superclass, 0); rb_define_method(cClassDefinition, "mixins", rdxr_definition_mixins, 0); @@ -401,10 +416,13 @@ void rdxi_initialize_definition(VALUE mod) { rb_define_method(cSingletonClassDefinition, "mixins", rdxr_definition_mixins, 0); cModuleDefinition = rb_define_class_under(mRubydex, "ModuleDefinition", cDefinition); + rb_define_method(cModuleDefinition, "source_name", rdxr_definition_source_name, 0); rb_define_method(cModuleDefinition, "mixins", rdxr_definition_mixins, 0); cConstantDefinition = rb_define_class_under(mRubydex, "ConstantDefinition", cDefinition); + rb_define_method(cConstantDefinition, "source_name", rdxr_definition_source_name, 0); cConstantAliasDefinition = rb_define_class_under(mRubydex, "ConstantAliasDefinition", cDefinition); + rb_define_method(cConstantAliasDefinition, "source_name", rdxr_definition_source_name, 0); cConstantVisibilityDefinition = rb_define_class_under(mRubydex, "ConstantVisibilityDefinition", cDefinition); cMethodVisibilityDefinition = rb_define_class_under(mRubydex, "MethodVisibilityDefinition", cDefinition); cMethodDefinition = rb_define_class_under(mRubydex, "MethodDefinition", cDefinition); diff --git a/rust/rubydex-sys/src/definition_api.rs b/rust/rubydex-sys/src/definition_api.rs index 9190b1bc8..83777e3b3 100644 --- a/rust/rubydex-sys/src/definition_api.rs +++ b/rust/rubydex-sys/src/definition_api.rs @@ -6,7 +6,9 @@ use crate::location_api::{Location, create_location_for_uri_and_offset}; use crate::reference_api::CConstantReference; use libc::c_char; use rubydex::model::definitions::{Definition, Mixin}; -use rubydex::model::ids::DefinitionId; +use rubydex::model::graph::Graph; +use rubydex::model::ids::{DefinitionId, NameId}; +use rubydex::model::name::ParentScope; use rubydex::query::AliasResolutionError; use std::ffi::CString; use std::ptr; @@ -110,6 +112,64 @@ pub unsafe extern "C" fn rdx_definition_name(pointer: GraphPointer, definition_i }) } +fn source_name_for_name(graph: &Graph, name_id: NameId) -> String { + let name = graph + .names() + .get(&name_id) + .expect("name should exist while building source_name"); + let simple_name_id = *name.str(); + let simple_name = graph + .strings() + .get(&simple_name_id) + .expect("string should exist while building source_name") + .as_str(); + + match name.parent_scope() { + ParentScope::None => simple_name.to_string(), + ParentScope::TopLevel => format!("::{simple_name}"), + ParentScope::Some(parent_id) | ParentScope::Attached(parent_id) => { + let parent_name = source_name_for_name(graph, *parent_id); + format!("{parent_name}::{simple_name}") + } + } +} + +fn definition_source_name_id(definition: &Definition) -> NameId { + match definition { + Definition::Class(definition) => *definition.name_id(), + Definition::Module(definition) => *definition.name_id(), + Definition::Constant(definition) => *definition.name_id(), + Definition::ConstantAlias(definition) => *definition.name_id(), + _ => panic!("source_name is only available for class, module, and constant definitions"), + } +} + +/// Returns the UTF-8 source name string for a class, module, or constant definition id. +/// +/// The source name is reconstructed from the definition's `Name`, preserving explicit parent scopes like `Foo::Bar` +/// and rooted paths like `::Bar`. Caller must free with `free_c_string`. +/// +/// # Safety +/// +/// Assumes pointer is valid. +/// +/// # Panics +/// +/// This function will panic if the definition cannot be found or if it is not a class, module, or constant definition. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_definition_source_name(pointer: GraphPointer, definition_id: u64) -> *const c_char { + with_graph(pointer, |graph| { + let def_id = DefinitionId::new(definition_id); + let definition = graph + .definitions() + .get(&def_id) + .unwrap_or_else(|| panic!("Definition not found: {definition_id:?}")); + + let name = source_name_for_name(graph, definition_source_name_id(definition)); + CString::new(name).unwrap().into_raw().cast_const() + }) +} + /// Shared iterator over definition (id, kind) pairs #[derive(Debug)] pub struct DefinitionsIter { diff --git a/test/definition_test.rb b/test/definition_test.rb index c0f8115d2..e1e7ca377 100644 --- a/test/definition_test.rb +++ b/test/definition_test.rb @@ -749,6 +749,45 @@ class ::Bar; end end end + def test_definition_source_name + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + class Bar; end + class ::Baz; end + module Qux::Quux; end + + CONST = 1 + ::ROOT_CONST = 2 + Foo::NESTED_CONST = 3 + ALIAS_CONST = Baz + + def foo; end + end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + + definitions = graph.documents.find { |d| d.uri == context.uri_to("file1.rb") }.definitions.to_a + source_names = definitions + .select { |definition| definition.respond_to?(:source_name) } + .map { |definition| [definition.class, definition.name, definition.source_name] } + + assert_includes(source_names, [Rubydex::ClassDefinition, "Foo", "Foo"]) + assert_includes(source_names, [Rubydex::ClassDefinition, "Bar", "Bar"]) + assert_includes(source_names, [Rubydex::ClassDefinition, "Baz", "::Baz"]) + assert_includes(source_names, [Rubydex::ModuleDefinition, "Quux", "Qux::Quux"]) + assert_includes(source_names, [Rubydex::ConstantDefinition, "CONST", "CONST"]) + assert_includes(source_names, [Rubydex::ConstantDefinition, "ROOT_CONST", "::ROOT_CONST"]) + assert_includes(source_names, [Rubydex::ConstantDefinition, "NESTED_CONST", "Foo::NESTED_CONST"]) + assert_includes(source_names, [Rubydex::ConstantAliasDefinition, "ALIAS_CONST", "ALIAS_CONST"]) + + method_def = definitions.find { |definition| definition.is_a?(Rubydex::MethodDefinition) } + refute_respond_to(method_def, :source_name) + end + end + def test_definition_declaration with_context do |context| context.write!("file1.rb", <<~RUBY)