From 0d1cb8be44b771c6975e6723458c3261ab028529 Mon Sep 17 00:00:00 2001 From: Alex Rocha Date: Thu, 5 Mar 2026 13:28:57 -0800 Subject: [PATCH 1/3] Add receiver field to MethodAliasDefinition Store an optional Receiver on method alias definitions to track how the alias was invoked. `self.alias_method` is semantically identical to bare `alias_method`, both alias instance methods on the current class, so we map it to None. Aliasing class methods requires `class << self`, which expresses singleton semantics through lexical nesting, not through a Receiver. This means no Ruby aliasing path produces a SelfReceiver, leaving that variant available for RBS `alias self.x self.y` (singleton aliases). --- rust/rubydex/src/indexing/ruby_indexer.rs | 114 +++++++++++++++++++++- rust/rubydex/src/model/definitions.rs | 11 ++- rust/rubydex/src/resolution.rs | 91 ++++++++++++++++- 3 files changed, 208 insertions(+), 8 deletions(-) diff --git a/rust/rubydex/src/indexing/ruby_indexer.rs b/rust/rubydex/src/indexing/ruby_indexer.rs index a4e16723..a473c6c6 100644 --- a/rust/rubydex/src/indexing/ruby_indexer.rs +++ b/rust/rubydex/src/indexing/ruby_indexer.rs @@ -1666,6 +1666,16 @@ impl Visit<'_> for RubyIndexer<'_> { let reference = MethodRef::new(old_name_str_id, self.uri_id, old_offset.clone(), method_receiver); self.local_graph.add_method_reference(reference); + // `self.alias_method` is semantically identical to bare `alias_method` — + // both alias instance methods on the current class. We map self to None so + // that SelfReceiver is only produced by the RBS indexer for `alias self.x self.y`. + let receiver = node.receiver().as_ref().and_then(|recv_node| match recv_node { + ruby_prism::Node::ConstantPathNode { .. } | ruby_prism::Node::ConstantReadNode { .. } => self + .index_constant_reference(recv_node, true) + .map(Receiver::ConstantReceiver), + _ => None, + }); + let offset = Offset::from_prism_location(&node.location()); let (comments, flags) = self.find_comments_for(offset.start()); @@ -1677,6 +1687,7 @@ impl Visit<'_> for RubyIndexer<'_> { comments, flags, self.current_nesting_definition_id(), + receiver, ))); let definition_id = self.local_graph.add_definition(definition); @@ -1987,6 +1998,7 @@ impl Visit<'_> for RubyIndexer<'_> { comments, flags, self.current_nesting_definition_id(), + None, ))); let definition_id = self.local_graph.add_definition(definition); @@ -4940,7 +4952,7 @@ mod tests { let old_name = context.graph().strings().get(def.old_name_str_id()).unwrap(); assert_eq!(new_name.as_str(), "foo()"); assert_eq!(old_name.as_str(), "bar()"); - + assert!(def.receiver().is_none()); assert_eq!(foo_class_def.id(), def.lexical_nesting_id().unwrap()); }); @@ -4949,7 +4961,7 @@ mod tests { let old_name = context.graph().strings().get(def.old_name_str_id()).unwrap(); assert_eq!(new_name.as_str(), "baz()"); assert_eq!(old_name.as_str(), "qux()"); - + assert!(def.receiver().is_none()); assert_eq!(foo_class_def.id(), def.lexical_nesting_id().unwrap()); }); }); @@ -5009,7 +5021,7 @@ mod tests { let old_name = context.graph().strings().get(def.old_name_str_id()).unwrap(); assert_eq!(new_name.as_str(), "foo_symbol()"); assert_eq!(old_name.as_str(), "bar_symbol()"); - + assert!(def.receiver().is_none()); assert!(def.lexical_nesting_id().is_none()); }); @@ -5018,7 +5030,7 @@ mod tests { let old_name = context.graph().strings().get(def.old_name_str_id()).unwrap(); assert_eq!(new_name.as_str(), "foo_string()"); assert_eq!(old_name.as_str(), "bar_string()"); - + assert!(def.receiver().is_none()); assert!(def.lexical_nesting_id().is_none()); }); @@ -5028,12 +5040,104 @@ mod tests { let old_name = context.graph().strings().get(def.old_name_str_id()).unwrap(); assert_eq!(new_name.as_str(), "baz()"); assert_eq!(old_name.as_str(), "qux()"); - + assert!(def.receiver().is_none()); assert_eq!(foo_class_def.id(), def.lexical_nesting_id().unwrap()); }); }); } + #[test] + fn index_alias_method_with_self_receiver_maps_to_none() { + let context = index_source({ + " + class Foo + self.alias_method :bar, :baz + end + " + }); + + assert_no_local_diagnostics!(&context); + + assert_definition_at!(&context, "2:3-2:31", MethodAlias, |def| { + assert!(def.receiver().is_none()); + }); + } + + #[test] + fn index_alias_method_with_constant_receiver_is_ignored() { + // `each_string_or_symbol_arg` skips constant receivers, so `Foo.alias_method(...)` + // does not produce a MethodAliasDefinition. + let context = index_source({ + " + class Foo; end + Foo.alias_method :bar, :baz + " + }); + + assert_no_local_diagnostics!(&context); + + let alias_count = context + .graph() + .definitions() + .values() + .filter(|d| matches!(d, Definition::MethodAlias(_))) + .count(); + assert_eq!(0, alias_count); + } + + #[test] + fn index_alias_method_in_singleton_class_has_no_receiver() { + let context = index_source({ + " + class Foo + def self.find; end + + class << self + alias_method :find_old, :find + end + end + " + }); + + assert_no_local_diagnostics!(&context); + + assert_definition_at!(&context, "1:1-7:4", Class, |_foo| { + assert_definition_at!(&context, "4:3-6:6", SingletonClass, |singleton| { + assert_definition_at!(&context, "5:5-5:34", MethodAlias, |def| { + assert_string_eq!(&context, def.new_name_str_id(), "find_old()"); + assert_string_eq!(&context, def.old_name_str_id(), "find()"); + assert!(def.receiver().is_none()); + assert_eq!(singleton.id(), def.lexical_nesting_id().unwrap()); + }); + }); + }); + } + + #[test] + fn index_alias_keyword_in_singleton_class_has_no_receiver() { + // Same as above: `alias` inside `class << self` has no receiver. + let context = index_source({ + " + class Foo + def self.find; end + + class << self + alias find_old find + end + end + " + }); + + assert_no_local_diagnostics!(&context); + + assert_definition_at!(&context, "4:3-6:6", SingletonClass, |singleton| { + assert_definition_at!(&context, "5:5-5:24", MethodAlias, |def| { + assert!(def.receiver().is_none()); + assert_eq!(singleton.id(), def.lexical_nesting_id().unwrap()); + }); + }); + } + #[test] fn index_alias_global_variables() { let context = index_source({ diff --git a/rust/rubydex/src/model/definitions.rs b/rust/rubydex/src/model/definitions.rs index d91ba3ab..9d657e45 100644 --- a/rust/rubydex/src/model/definitions.rs +++ b/rust/rubydex/src/model/definitions.rs @@ -1361,10 +1361,12 @@ pub struct MethodAliasDefinition { flags: DefinitionFlags, comments: Box<[Comment]>, lexical_nesting_id: Option, + receiver: Option, } -assert_mem_size!(MethodAliasDefinition, 72); +assert_mem_size!(MethodAliasDefinition, 88); impl MethodAliasDefinition { + #[allow(clippy::too_many_arguments)] #[must_use] pub const fn new( new_name_str_id: StringId, @@ -1374,6 +1376,7 @@ impl MethodAliasDefinition { comments: Box<[Comment]>, flags: DefinitionFlags, lexical_nesting_id: Option, + receiver: Option, ) -> Self { Self { new_name_str_id, @@ -1383,6 +1386,7 @@ impl MethodAliasDefinition { flags, comments, lexical_nesting_id, + receiver, } } @@ -1427,6 +1431,11 @@ impl MethodAliasDefinition { &self.lexical_nesting_id } + #[must_use] + pub fn receiver(&self) -> &Option { + &self.receiver + } + #[must_use] pub fn flags(&self) -> &DefinitionFlags { &self.flags diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index 2c914f41..836b4c56 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -509,8 +509,25 @@ impl<'a> Resolver<'a> { } } Definition::MethodAlias(alias) => { - let Some(owner_id) = self.resolve_lexical_owner(*alias.lexical_nesting_id()) else { - continue; + // Method aliases operate on instance methods. The SelfReceiver arm is for + // RBS `alias self.x self.y`. + let owner_id = match alias.receiver() { + Some(Receiver::SelfReceiver(def_id)) => *self + .graph + .definition_id_to_declaration_id(*def_id) + .expect("SelfReceiver definition should have a declaration"), + Some(Receiver::ConstantReceiver(name_id)) => match self.graph.names().get(name_id).unwrap() { + NameRef::Resolved(resolved) => *resolved.declaration_id(), + NameRef::Unresolved(_) => { + continue; + } + }, + None => { + let Some(resolved) = self.resolve_lexical_owner(*alias.lexical_nesting_id()) else { + continue; + }; + resolved + } }; self.create_declaration(*alias.new_name_str_id(), id, owner_id, |name| { @@ -2540,6 +2557,76 @@ mod tests { assert_members_eq!(context, "Foo", ["bar()", "foo()"]); } + #[test] + fn resolving_method_alias_with_self_receiver() { + // SelfReceiver resolves to instance methods (the class directly), not the singleton + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Foo + def original; end + self.alias_method :aliased, :original + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_members_eq!(context, "Foo", ["aliased()", "original()"]); + } + + #[test] + fn resolving_alias_method_in_singleton_class_lands_on_singleton() { + // `class << self; alias_method ...; end` — alias lands on singleton via lexical nesting + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Foo + def self.find; end + + class << self + alias_method :find_old, :find + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_members_eq!(context, "Foo::", ["find()", "find_old()"]); + } + + #[test] + fn resolving_self_alias_method_is_equivalent_to_bare_alias_method() { + // `self.alias_method` and bare `alias_method` resolve identically (instance methods) + let mut context = GraphTest::new(); + context.index_uri("file:///with_self.rb", { + r" + class WithSelf + def original; end + self.alias_method :aliased, :original + end + " + }); + context.index_uri("file:///without_self.rb", { + r" + class WithoutSelf + def original; end + alias_method :aliased, :original + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Both resolve identically: alias lands on instance methods + assert_members_eq!(context, "WithSelf", ["aliased()", "original()"]); + assert_members_eq!(context, "WithoutSelf", ["aliased()", "original()"]); + } + #[test] fn resolving_global_variable_alias() { let mut context = GraphTest::new(); From b8227862b23090967cc9064cc167ea4a13fe94e6 Mon Sep 17 00:00:00 2001 From: Alex Rocha Date: Wed, 11 Mar 2026 22:18:04 -0700 Subject: [PATCH 2/3] Support constant receivers for alias_method Add a dynamic receiver guard to skip non-constant, non-self receivers (e.g. foo.alias_method). Fix double-indexing for nested constant receivers by calling index_constant_reference once instead of through both the receiver and method_receiver paths --- rust/rubydex/src/indexing/ruby_indexer.rs | 88 +++++++++++++++-------- rust/rubydex/src/resolution.rs | 23 ++++++ 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/rust/rubydex/src/indexing/ruby_indexer.rs b/rust/rubydex/src/indexing/ruby_indexer.rs index a473c6c6..6138491f 100644 --- a/rust/rubydex/src/indexing/ruby_indexer.rs +++ b/rust/rubydex/src/indexing/ruby_indexer.rs @@ -361,17 +361,12 @@ impl<'a> RubyIndexer<'a> { }) } - // Runs the given closure if the given call `node` is invoked directly on `self` for each one of its string or - // symbol arguments + // Runs the given closure for each string or symbol argument of a call node. fn each_string_or_symbol_arg(node: &ruby_prism::CallNode, mut f: F) where F: FnMut(String, ruby_prism::Location), { - let receiver = node.receiver(); - - if (receiver.is_none() || receiver.unwrap().as_self_node().is_some()) - && let Some(arguments) = node.arguments() - { + if let Some(arguments) = node.arguments() { for argument in &arguments.arguments() { match argument { ruby_prism::Node::SymbolNode { .. } => { @@ -1549,6 +1544,11 @@ impl Visit<'_> for RubyIndexer<'_> { } let mut index_attr = |kind: AttrKind, call: &ruby_prism::CallNode| { + let receiver = call.receiver(); + if receiver.is_some() && receiver.unwrap().as_self_node().is_none() { + return; + } + let call_offset = Offset::from_prism_location(&call.location()); Self::each_string_or_symbol_arg(call, |name, location| { @@ -1645,6 +1645,21 @@ impl Visit<'_> for RubyIndexer<'_> { } } "alias_method" => { + let recv_node = node.receiver(); + let recv_ref = recv_node.as_ref(); + if recv_ref.is_some_and(|recv| { + !matches!( + recv, + ruby_prism::Node::SelfNode { .. } + | ruby_prism::Node::ConstantReadNode { .. } + | ruby_prism::Node::ConstantPathNode { .. } + ) + }) { + // TODO: Add a diagnostic for dynamic receivers + self.visit_call_node_parts(node); + return; + } + let mut names: Vec<(String, Offset)> = Vec::new(); Self::each_string_or_symbol_arg(node, |name, location| { @@ -1662,20 +1677,18 @@ impl Visit<'_> for RubyIndexer<'_> { let new_name_str_id = self.local_graph.intern_string(format!("{new_name}()")); let old_name_str_id = self.local_graph.intern_string(format!("{old_name}()")); - let method_receiver = self.method_receiver(node.receiver().as_ref(), node.location()); + let (receiver, method_receiver) = match recv_ref { + Some( + recv @ (ruby_prism::Node::ConstantPathNode { .. } | ruby_prism::Node::ConstantReadNode { .. }), + ) => { + let name_id = self.index_constant_reference(recv, true); + (name_id.map(Receiver::ConstantReceiver), name_id) + } + _ => (None, self.method_receiver(recv_ref, node.location())), + }; let reference = MethodRef::new(old_name_str_id, self.uri_id, old_offset.clone(), method_receiver); self.local_graph.add_method_reference(reference); - // `self.alias_method` is semantically identical to bare `alias_method` — - // both alias instance methods on the current class. We map self to None so - // that SelfReceiver is only produced by the RBS indexer for `alias self.x self.y`. - let receiver = node.receiver().as_ref().and_then(|recv_node| match recv_node { - ruby_prism::Node::ConstantPathNode { .. } | ruby_prism::Node::ConstantReadNode { .. } => self - .index_constant_reference(recv_node, true) - .map(Receiver::ConstantReceiver), - _ => None, - }); - let offset = Offset::from_prism_location(&node.location()); let (comments, flags) = self.find_comments_for(offset.start()); @@ -5064,9 +5077,7 @@ mod tests { } #[test] - fn index_alias_method_with_constant_receiver_is_ignored() { - // `each_string_or_symbol_arg` skips constant receivers, so `Foo.alias_method(...)` - // does not produce a MethodAliasDefinition. + fn index_alias_method_with_constant_receiver() { let context = index_source({ " class Foo; end @@ -5076,13 +5087,11 @@ mod tests { assert_no_local_diagnostics!(&context); - let alias_count = context - .graph() - .definitions() - .values() - .filter(|d| matches!(d, Definition::MethodAlias(_))) - .count(); - assert_eq!(0, alias_count); + assert_definition_at!(&context, "2:1-2:28", MethodAlias, |def| { + assert_string_eq!(&context, def.new_name_str_id(), "bar()"); + assert_string_eq!(&context, def.old_name_str_id(), "baz()"); + assert_method_has_receiver!(&context, def, "Foo"); + }); } #[test] @@ -5138,6 +5147,29 @@ mod tests { }); } + #[test] + fn index_alias_method_with_nested_constant_receiver() { + let context = index_source({ + " + module A + class B + def original; end + end + end + + A::B.alias_method :new_name, :original + " + }); + + assert_no_local_diagnostics!(&context); + + assert_definition_at!(&context, "7:1-7:39", MethodAlias, |def| { + assert_string_eq!(&context, def.new_name_str_id(), "new_name()"); + assert_method_has_receiver!(&context, def, "B"); + assert!(def.lexical_nesting_id().is_none()); + }); + } + #[test] fn index_alias_global_variables() { let context = index_source({ diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index 836b4c56..266779a3 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -2627,6 +2627,29 @@ mod tests { assert_members_eq!(context, "WithoutSelf", ["aliased()", "original()"]); } + #[test] + fn resolving_method_alias_with_constant_receiver() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Bar + def to_s; end + end + + class Foo + Bar.alias_method(:new_to_s, :to_s) + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Bar.alias_method places the alias on Bar's instance methods + assert_no_members!(context, "Foo"); + assert_members_eq!(context, "Bar", ["new_to_s()", "to_s()"]); + } + #[test] fn resolving_global_variable_alias() { let mut context = GraphTest::new(); From 0cd9b77b1cfbb1eda9e7b41469ccfa21e6dacb3b Mon Sep 17 00:00:00 2001 From: Alex Rocha Date: Fri, 13 Mar 2026 14:48:29 -0700 Subject: [PATCH 3/3] Add edge case test for method alias indexing --- rust/rubydex/src/indexing/ruby_indexer.rs | 26 ++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/rust/rubydex/src/indexing/ruby_indexer.rs b/rust/rubydex/src/indexing/ruby_indexer.rs index 6138491f..ef86d451 100644 --- a/rust/rubydex/src/indexing/ruby_indexer.rs +++ b/rust/rubydex/src/indexing/ruby_indexer.rs @@ -4578,6 +4578,7 @@ mod tests { assert_definition_at!(&context, "1:1-5:4", Class, |foo| { assert_definition_at!(&context, "3:5-3:24", MethodAlias, |alias_method| { + assert!(alias_method.receiver().is_none()); assert_eq!(foo.id(), alias_method.lexical_nesting_id().unwrap()); }); }); @@ -4996,7 +4997,7 @@ mod tests { let old_name = context.graph().strings().get(def.old_name_str_id()).unwrap(); assert_eq!(new_name.as_str(), "foo()"); assert_eq!(old_name.as_str(), "bar()"); - + assert!(def.receiver().is_none()); assert!(def.lexical_nesting_id().is_none()); }); @@ -5170,6 +5171,29 @@ mod tests { }); } + #[test] + fn index_alias_method_with_dynamic_receiver_not_indexed() { + let context = index_source({ + " + class Foo + def original; end + end + + foo.alias_method :new_name, :original + " + }); + + assert_no_local_diagnostics!(&context); + + let alias_count = context + .graph() + .definitions() + .values() + .filter(|def| matches!(def, Definition::MethodAlias(_))) + .count(); + assert_eq!(0, alias_count); + } + #[test] fn index_alias_global_variables() { let context = index_source({