diff --git a/ext/rubydex/definition.c b/ext/rubydex/definition.c index 23aab590..c1146666 100644 --- a/ext/rubydex/definition.c +++ b/ext/rubydex/definition.c @@ -1,4 +1,5 @@ #include "definition.h" +#include "declaration.h" #include "graph.h" #include "handle.h" #include "location.h" @@ -264,6 +265,36 @@ static VALUE rdxr_method_alias_definition_signatures(VALUE self) { return rdxi_signatures_to_ruby(arr); } +// MethodAliasDefinition#target -> Rubydex::Method? +// Returns the resolved target method declaration by following the alias chain, or nil if the chain could not be +// resolved (the target name doesn't exist on the owner, or the owner itself never resolved). Raises +// Rubydex::AliasCycleError when the alias chain forms a cycle. +static VALUE rdxr_method_alias_definition_target(VALUE self) { + HandleData *data; + TypedData_Get_Struct(self, HandleData, &handle_type, data); + + void *graph; + TypedData_Get_Struct(data->graph_obj, void *, &graph_type, graph); + + CMethodAliasTargetResult result = rdx_method_alias_definition_target(graph, data->id); + + switch (result.status) { + case CMethodAliasResolution_Resolved: { + VALUE decl_class = rdxi_declaration_class_for_kind(result.declaration->kind); + VALUE argv[] = {data->graph_obj, ULL2NUM(result.declaration->id)}; + + free_c_declaration(result.declaration); + return rb_class_new_instance(2, argv, decl_class); + } + case CMethodAliasResolution_NotFound: + return Qnil; + case CMethodAliasResolution_Cycle: + rb_raise(rb_const_get(mRubydex, rb_intern("AliasCycleError")), "method alias chain forms a cycle"); + default: + rb_raise(rb_eRuntimeError, "Unknown CMethodAliasResolution: %d", result.status); + } +} + void rdxi_initialize_definition(VALUE mod) { mRubydex = mod; @@ -307,5 +338,6 @@ void rdxi_initialize_definition(VALUE mod) { cClassVariableDefinition = rb_define_class_under(mRubydex, "ClassVariableDefinition", cDefinition); cMethodAliasDefinition = rb_define_class_under(mRubydex, "MethodAliasDefinition", cDefinition); rb_define_method(cMethodAliasDefinition, "signatures", rdxr_method_alias_definition_signatures, 0); + rb_define_method(cMethodAliasDefinition, "target", rdxr_method_alias_definition_target, 0); cGlobalVariableAliasDefinition = rb_define_class_under(mRubydex, "GlobalVariableAliasDefinition", cDefinition); } diff --git a/lib/rubydex.rb b/lib/rubydex.rb index 2e0df7e3..847ac16e 100644 --- a/lib/rubydex.rb +++ b/lib/rubydex.rb @@ -14,6 +14,7 @@ require "rubydex/rubydex" end +require "rubydex/errors" require "rubydex/failures" require "rubydex/location" require "rubydex/comment" diff --git a/lib/rubydex/errors.rb b/lib/rubydex/errors.rb new file mode 100644 index 00000000..599ea5f8 --- /dev/null +++ b/lib/rubydex/errors.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Rubydex + class Error < StandardError; end + + # Raised when `MethodAliasDefinition#target` walks an alias chain that loops back on itself. + class AliasCycleError < Error; end +end diff --git a/rbi/rubydex.rbi b/rbi/rubydex.rbi index db2b1a86..42feab1d 100644 --- a/rbi/rubydex.rbi +++ b/rbi/rubydex.rbi @@ -150,7 +150,10 @@ class Rubydex::ConstantDefinition < Rubydex::Definition; end class Rubydex::GlobalVariableAliasDefinition < Rubydex::Definition; end class Rubydex::GlobalVariableDefinition < Rubydex::Definition; end class Rubydex::InstanceVariableDefinition < Rubydex::Definition; end -class Rubydex::MethodAliasDefinition < Rubydex::Definition; end +class Rubydex::MethodAliasDefinition < Rubydex::Definition + sig { returns(T.nilable(Rubydex::Method)) } + def target; end +end class Rubydex::MethodDefinition < Rubydex::Definition; end class Rubydex::ModuleDefinition < Rubydex::Definition @@ -231,6 +234,7 @@ class Rubydex::Document end class Rubydex::Error < StandardError; end +class Rubydex::AliasCycleError < Rubydex::Error; end class Rubydex::Failure sig { params(message: String).void } diff --git a/rust/rubydex-sys/src/definition_api.rs b/rust/rubydex-sys/src/definition_api.rs index b96f85af..90e895ac 100644 --- a/rust/rubydex-sys/src/definition_api.rs +++ b/rust/rubydex-sys/src/definition_api.rs @@ -1,11 +1,13 @@ //! This file provides the C API for Definition accessors +use crate::declaration_api::CDeclaration; use crate::graph_api::{GraphPointer, with_graph}; 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::query::AliasResolutionError; use std::ffi::CString; use std::ptr; @@ -441,3 +443,72 @@ pub unsafe extern "C" fn rdx_definition_mixins(pointer: GraphPointer, definition MixinsIter::new(entries.into_boxed_slice()) }) } + +/// Status of a `MethodAliasDefinition#target` resolution. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CMethodAliasResolution { + /// The alias chain resolved successfully; `declaration` is valid. + Resolved = 0, + /// The chain could not be resolved because the target name does not exist on the owner, or the owner itself was + /// never resolved. Treated as `nil` on the Ruby side. + NotFound = 1, + /// The alias chain forms a cycle. Surfaced as a `Rubydex::AliasCycleError` on the Ruby side. + Cycle = 2, +} + +#[repr(C)] +#[derive(Debug)] +pub struct CMethodAliasTargetResult { + pub status: CMethodAliasResolution, + pub declaration: *const CDeclaration, +} + +/// Resolves a `MethodAliasDefinition` to its target method declaration via `query::follow_method_alias` and reports the +/// outcome as a tagged status. The `declaration` pointer is non-null only when `status == Resolved`; the caller is +/// responsible for freeing it with `free_c_declaration`. +/// +/// # Safety +/// - `pointer` must be a valid pointer previously returned by `rdx_graph_new`. +/// - `definition_id` must be a valid definition id for a `MethodAliasDefinition`. +/// +/// # Panics +/// Panics on graph inconsistencies (the definition is not a method alias, or the alias resolved to a non-method +/// declaration). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_method_alias_definition_target( + pointer: GraphPointer, + definition_id: u64, +) -> CMethodAliasTargetResult { + with_graph(pointer, |graph| { + let def_id = DefinitionId::new(definition_id); + + match rubydex::query::follow_method_alias(graph, def_id) { + Ok(target_id) => { + let target_decl = graph + .declarations() + .get(&target_id) + .expect("target declaration must exist"); + let boxed = Box::new(CDeclaration::from_declaration(target_id, target_decl)); + + CMethodAliasTargetResult { + status: CMethodAliasResolution::Resolved, + declaration: Box::into_raw(boxed).cast_const(), + } + } + Err(AliasResolutionError::TargetNotFound | AliasResolutionError::UnresolvedOwner) => { + CMethodAliasTargetResult { + status: CMethodAliasResolution::NotFound, + declaration: ptr::null(), + } + } + Err(AliasResolutionError::Cycle) => CMethodAliasTargetResult { + status: CMethodAliasResolution::Cycle, + declaration: ptr::null(), + }, + Err(err @ (AliasResolutionError::NotAnAlias | AliasResolutionError::TargetNotMethod)) => { + panic!("graph inconsistency in method alias resolution: {err:?}") + } + } + }) +} diff --git a/test/definition_test.rb b/test/definition_test.rb index 09b89a76..4df2d59f 100644 --- a/test/definition_test.rb +++ b/test/definition_test.rb @@ -668,6 +668,89 @@ class Foo end end + def test_method_alias_definition_target + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + def foo(a, b); end + alias bar foo + end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + alias_def = graph["Foo#bar()"].definitions.first + assert_instance_of(Rubydex::MethodAliasDefinition, alias_def) + + target = alias_def.target + assert_instance_of(Rubydex::Method, target) + assert_equal("Foo#foo()", target.name) + end + end + + def test_method_alias_definition_target_through_chain + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + def foo; end + alias bar foo + alias baz bar + end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + alias_def = graph["Foo#baz()"].definitions.first + assert_instance_of(Rubydex::MethodAliasDefinition, alias_def) + + target = alias_def.target + assert_instance_of(Rubydex::Method, target) + assert_equal("Foo#foo()", target.name) + end + end + + def test_method_alias_definition_target_unresolved_returns_nil + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + alias bar nonexistent + end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + alias_def = graph["Foo#bar()"].definitions.first + assert_instance_of(Rubydex::MethodAliasDefinition, alias_def) + assert_nil(alias_def.target) + end + end + + def test_method_alias_definition_target_raises_on_cycle + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + alias a b + alias b a + end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + alias_def = graph["Foo#a()"].definitions.first + assert_instance_of(Rubydex::MethodAliasDefinition, alias_def) + + assert_raises(Rubydex::AliasCycleError) { alias_def.target } + end + end + private # Comment locations on Windows include the carriage return. This means that the end column is off by one when compared