diff --git a/ext/rubydex/definition.c b/ext/rubydex/definition.c index 887fb27e..775a3128 100644 --- a/ext/rubydex/definition.c +++ b/ext/rubydex/definition.c @@ -5,6 +5,7 @@ #include "reference.h" #include "ruby/internal/scan_args.h" #include "rustbindings.h" +#include "signature.h" static VALUE mRubydex; static VALUE cInclude; @@ -239,6 +240,30 @@ static VALUE rdxr_definition_mixins(VALUE self) { return ary; } +// MethodDefinition#signatures -> [Rubydex::Signature] +static VALUE rdxr_method_definition_signatures(VALUE self) { + HandleData *data; + TypedData_Get_Struct(self, HandleData, &handle_type, data); + + void *graph; + TypedData_Get_Struct(data->graph_obj, void *, &graph_type, graph); + + SignatureArray *arr = rdx_definition_signatures(graph, data->id); + return rdxi_signatures_to_ruby(arr); +} + +// MethodAliasDefinition#signatures -> [Rubydex::Signature] +static VALUE rdxr_method_alias_definition_signatures(VALUE self) { + HandleData *data; + TypedData_Get_Struct(self, HandleData, &handle_type, data); + + void *graph; + TypedData_Get_Struct(data->graph_obj, void *, &graph_type, graph); + + SignatureArray *arr = rdx_method_alias_definition_signatures(graph, data->id); + return rdxi_signatures_to_ruby(arr); +} + void rdxi_initialize_definition(VALUE mod) { mRubydex = mod; @@ -273,6 +298,7 @@ void rdxi_initialize_definition(VALUE mod) { cConstantVisibilityDefinition = rb_define_class_under(mRubydex, "ConstantVisibilityDefinition", cDefinition); cMethodVisibilityDefinition = rb_define_class_under(mRubydex, "MethodVisibilityDefinition", cDefinition); cMethodDefinition = rb_define_class_under(mRubydex, "MethodDefinition", cDefinition); + rb_define_method(cMethodDefinition, "signatures", rdxr_method_definition_signatures, 0); cAttrAccessorDefinition = rb_define_class_under(mRubydex, "AttrAccessorDefinition", cDefinition); cAttrReaderDefinition = rb_define_class_under(mRubydex, "AttrReaderDefinition", cDefinition); cAttrWriterDefinition = rb_define_class_under(mRubydex, "AttrWriterDefinition", cDefinition); @@ -280,5 +306,6 @@ void rdxi_initialize_definition(VALUE mod) { cInstanceVariableDefinition = rb_define_class_under(mRubydex, "InstanceVariableDefinition", cDefinition); 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); cGlobalVariableAliasDefinition = rb_define_class_under(mRubydex, "GlobalVariableAliasDefinition", cDefinition); } diff --git a/ext/rubydex/rubydex.c b/ext/rubydex/rubydex.c index 8f00e2c3..ea6b7d3d 100644 --- a/ext/rubydex/rubydex.c +++ b/ext/rubydex/rubydex.c @@ -5,6 +5,7 @@ #include "graph.h" #include "location.h" #include "reference.h" +#include "signature.h" VALUE mRubydex; @@ -19,4 +20,5 @@ void Init_rubydex(void) { rdxi_initialize_location(mRubydex); rdxi_initialize_diagnostic(mRubydex); rdxi_initialize_reference(mRubydex); + rdxi_initialize_signature(mRubydex); } diff --git a/ext/rubydex/signature.c b/ext/rubydex/signature.c new file mode 100644 index 00000000..6019bc35 --- /dev/null +++ b/ext/rubydex/signature.c @@ -0,0 +1,72 @@ +#include "signature.h" +#include "location.h" + +VALUE cSignature; +VALUE cParameter; +VALUE cPositionalParameter; +VALUE cOptionalPositionalParameter; +VALUE cRestPositionalParameter; +VALUE cPostParameter; +VALUE cKeywordParameter; +VALUE cOptionalKeywordParameter; +VALUE cRestKeywordParameter; +VALUE cForwardParameter; +VALUE cBlockParameter; + +static VALUE parameter_class_for_kind(ParameterKind kind) { + switch (kind) { + case ParameterKind_RequiredPositional: return cPositionalParameter; + case ParameterKind_OptionalPositional: return cOptionalPositionalParameter; + case ParameterKind_RestPositional: return cRestPositionalParameter; + case ParameterKind_Post: return cPostParameter; + case ParameterKind_RequiredKeyword: return cKeywordParameter; + case ParameterKind_OptionalKeyword: return cOptionalKeywordParameter; + case ParameterKind_RestKeyword: return cRestKeywordParameter; + case ParameterKind_Forward: return cForwardParameter; + case ParameterKind_Block: return cBlockParameter; + default: rb_raise(rb_eRuntimeError, "Unknown ParameterKind: %d", kind); + } +} + +VALUE rdxi_signatures_to_ruby(SignatureArray *arr) { + VALUE signatures = rb_ary_new_capa((long)arr->len); + + for (size_t i = 0; i < arr->len; i++) { + SignatureEntry sig_entry = arr->items[i]; + + VALUE parameters = rb_ary_new_capa((long)sig_entry.parameters_len); + for (size_t j = 0; j < sig_entry.parameters_len; j++) { + ParameterEntry param_entry = sig_entry.parameters[j]; + + VALUE param_class = parameter_class_for_kind(param_entry.kind); + VALUE name_sym = rb_str_intern(rb_utf8_str_new_cstr(param_entry.name)); + VALUE location = rdxi_build_location_value(param_entry.location); + VALUE param_argv[] = {name_sym, location}; + VALUE param = rb_class_new_instance(2, param_argv, param_class); + + rb_ary_push(parameters, param); + } + + VALUE signature = rb_class_new_instance(1, ¶meters, cSignature); + + rb_ary_push(signatures, signature); + } + + rdx_definition_signatures_free(arr); + return signatures; +} + +void rdxi_initialize_signature(VALUE mRubydex) { + cSignature = rb_define_class_under(mRubydex, "Signature", rb_cObject); + + cParameter = rb_define_class_under(cSignature, "Parameter", rb_cObject); + cPositionalParameter = rb_define_class_under(cSignature, "PositionalParameter", cParameter); + cOptionalPositionalParameter = rb_define_class_under(cSignature, "OptionalPositionalParameter", cParameter); + cRestPositionalParameter = rb_define_class_under(cSignature, "RestPositionalParameter", cParameter); + cPostParameter = rb_define_class_under(cSignature, "PostParameter", cParameter); + cKeywordParameter = rb_define_class_under(cSignature, "KeywordParameter", cParameter); + cOptionalKeywordParameter = rb_define_class_under(cSignature, "OptionalKeywordParameter", cParameter); + cRestKeywordParameter = rb_define_class_under(cSignature, "RestKeywordParameter", cParameter); + cForwardParameter = rb_define_class_under(cSignature, "ForwardParameter", cParameter); + cBlockParameter = rb_define_class_under(cSignature, "BlockParameter", cParameter); +} diff --git a/ext/rubydex/signature.h b/ext/rubydex/signature.h new file mode 100644 index 00000000..156e4fb6 --- /dev/null +++ b/ext/rubydex/signature.h @@ -0,0 +1,25 @@ +#ifndef RUBYDEX_SIGNATURE_H +#define RUBYDEX_SIGNATURE_H + +#include "ruby.h" +#include "rustbindings.h" + +extern VALUE cSignature; +extern VALUE cParameter; +extern VALUE cPositionalParameter; +extern VALUE cOptionalPositionalParameter; +extern VALUE cRestPositionalParameter; +extern VALUE cPostParameter; +extern VALUE cKeywordParameter; +extern VALUE cOptionalKeywordParameter; +extern VALUE cRestKeywordParameter; +extern VALUE cForwardParameter; +extern VALUE cBlockParameter; + +// Convert a SignatureArray into a Ruby array of Rubydex::Signature objects. +// The SignatureArray is freed after conversion. +VALUE rdxi_signatures_to_ruby(SignatureArray *arr); + +void rdxi_initialize_signature(VALUE mRubydex); + +#endif // RUBYDEX_SIGNATURE_H diff --git a/lib/rubydex.rb b/lib/rubydex.rb index e44b9a28..d7903e2a 100644 --- a/lib/rubydex.rb +++ b/lib/rubydex.rb @@ -17,6 +17,7 @@ require "rubydex/failures" require "rubydex/location" require "rubydex/comment" +require "rubydex/signature" require "rubydex/diagnostic" require "rubydex/keyword" require "rubydex/keyword_parameter" diff --git a/lib/rubydex/signature.rb b/lib/rubydex/signature.rb new file mode 100644 index 00000000..a09e674e --- /dev/null +++ b/lib/rubydex/signature.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Rubydex + class Signature + class Parameter + #: Symbol + attr_reader :name + + #: Location + attr_reader :location + + #: (Symbol, Location) -> void + def initialize(name, location) + @name = name + @location = location + end + end + + #: Array[Parameter] + attr_reader :parameters + + #: (Array[Parameter]) -> void + def initialize(parameters) + @parameters = parameters + end + end +end diff --git a/rust/rubydex-sys/src/declaration_api.rs b/rust/rubydex-sys/src/declaration_api.rs index 0b05231a..52066fa6 100644 --- a/rust/rubydex-sys/src/declaration_api.rs +++ b/rust/rubydex-sys/src/declaration_api.rs @@ -178,42 +178,15 @@ pub unsafe extern "C" fn rdx_declaration_find_member( with_graph(pointer, |graph| { let id = DeclarationId::new(declaration_id); + let member_id = StringId::from(member_str.as_str()); - let Some(Declaration::Namespace(decl)) = graph.declarations().get(&id) else { + let Some(member_decl_id) = rubydex::query::find_member_in_ancestors(graph, id, member_id, only_inherited) + else { return ptr::null(); }; - let member_id = StringId::from(member_str.as_str()); - let mut found_main_namespace = false; - - decl.ancestors() - .iter() - .find_map(|ancestor| match ancestor { - Ancestor::Complete(ancestor_id) => { - // When only_inherited, skip self and prepended modules - if only_inherited { - let is_self = *ancestor_id == id; - if is_self { - found_main_namespace = true; - } - if is_self || !found_main_namespace { - return None; - } - } - - let ancestor_decl = graph.declarations().get(ancestor_id).unwrap().as_namespace().unwrap(); - - if let Some(member_decl_id) = ancestor_decl.member(&member_id) { - return Some((member_decl_id, graph.declarations().get(member_decl_id).unwrap())); - } - - None - } - Ancestor::Partial(_) => None, - }) - .map_or(ptr::null(), |(member_decl_id, member_decl)| { - Box::into_raw(Box::new(CDeclaration::from_declaration(*member_decl_id, member_decl))).cast_const() - }) + let member_decl = graph.declarations().get(&member_decl_id).unwrap(); + Box::into_raw(Box::new(CDeclaration::from_declaration(member_decl_id, member_decl))).cast_const() }) } diff --git a/rust/rubydex-sys/src/lib.rs b/rust/rubydex-sys/src/lib.rs index 202dc973..b5108689 100644 --- a/rust/rubydex-sys/src/lib.rs +++ b/rust/rubydex-sys/src/lib.rs @@ -76,4 +76,5 @@ pub mod graph_api; pub mod location_api; pub mod name_api; pub mod reference_api; +pub mod signature_api; pub mod utils; diff --git a/rust/rubydex-sys/src/signature_api.rs b/rust/rubydex-sys/src/signature_api.rs new file mode 100644 index 00000000..f28fc529 --- /dev/null +++ b/rust/rubydex-sys/src/signature_api.rs @@ -0,0 +1,211 @@ +//! This file provides the C API for method signature accessors + +use crate::graph_api::{GraphPointer, with_graph}; +use crate::location_api::{Location, create_location_for_uri_and_offset}; +use libc::c_char; +use rubydex::model::definitions::{Definition, MethodDefinition, Parameter}; +use rubydex::model::graph::Graph; +use rubydex::model::ids::DefinitionId; +use std::ffi::CString; +use std::ptr; + +/// C-compatible enum representing the kind of a parameter. +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ParameterKind { + RequiredPositional = 0, + OptionalPositional = 1, + RestPositional = 2, + Post = 3, + RequiredKeyword = 4, + OptionalKeyword = 5, + RestKeyword = 6, + Forward = 7, + Block = 8, +} + +fn map_parameter_kind(param: &Parameter) -> ParameterKind { + match param { + Parameter::RequiredPositional(_) => ParameterKind::RequiredPositional, + Parameter::Post(_) => ParameterKind::Post, + Parameter::OptionalPositional(_) => ParameterKind::OptionalPositional, + Parameter::RestPositional(_) => ParameterKind::RestPositional, + Parameter::RequiredKeyword(_) => ParameterKind::RequiredKeyword, + Parameter::OptionalKeyword(_) => ParameterKind::OptionalKeyword, + Parameter::RestKeyword(_) => ParameterKind::RestKeyword, + Parameter::Block(_) => ParameterKind::Block, + Parameter::Forward(_) => ParameterKind::Forward, + } +} + +/// C-compatible struct representing a single parameter with its name, kind, and location. +#[repr(C)] +pub struct ParameterEntry { + pub name: *const c_char, + pub kind: ParameterKind, + pub location: *mut Location, +} + +/// C-compatible struct representing a single method signature (a list of parameters). +#[repr(C)] +pub struct SignatureEntry { + pub definition_id: u64, + pub parameters: *mut ParameterEntry, + pub parameters_len: usize, +} + +/// C-compatible array of signatures. +#[repr(C)] +pub struct SignatureArray { + pub items: *mut SignatureEntry, + pub len: usize, +} + +/// Returns a newly allocated array of signatures for the given method definition id. +/// Caller must free the returned pointer with `rdx_definition_signatures_free`. +/// +/// # Safety +/// - `pointer` must be a valid pointer previously returned by `rdx_graph_new`. +/// - `definition_id` must be a valid definition id. +/// +/// # Panics +/// Panics if `definition_id` does not exist or is not a `MethodDefinition`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_definition_signatures(pointer: GraphPointer, definition_id: u64) -> *mut SignatureArray { + with_graph(pointer, |graph| { + let def_id = DefinitionId::new(definition_id); + let Definition::Method(method_def) = graph.definitions().get(&def_id).expect("definition should exist") else { + panic!("expected a method definition"); + }; + + let mut sig_entries: Vec = Vec::new(); + collect_method_signatures(graph, method_def, definition_id, &mut sig_entries); + + let mut boxed = sig_entries.into_boxed_slice(); + let len = boxed.len(); + let items_ptr = boxed.as_mut_ptr(); + std::mem::forget(boxed); + + Box::into_raw(Box::new(SignatureArray { items: items_ptr, len })) + }) +} + +/// Helper: build signature entries from a `MethodDefinition` and append them to the output vector. +fn collect_method_signatures( + graph: &Graph, + method_def: &MethodDefinition, + definition_id: u64, + out: &mut Vec, +) { + let uri_id = *method_def.uri_id(); + let document = graph.documents().get(&uri_id).expect("document should exist"); + + for sig in method_def.signatures().as_slice() { + let mut param_entries = sig + .iter() + .map(|param| { + let param_struct = param.inner(); + let name = graph + .strings() + .get(param_struct.str()) + .expect("parameter name string should exist"); + let name_str = CString::new(name.as_str()).unwrap().into_raw().cast_const(); + + ParameterEntry { + name: name_str, + kind: map_parameter_kind(param), + location: create_location_for_uri_and_offset(graph, document, param_struct.offset()), + } + }) + .collect::>() + .into_boxed_slice(); + + let parameters_len = param_entries.len(); + let parameters_ptr = param_entries.as_mut_ptr(); + std::mem::forget(param_entries); + + out.push(SignatureEntry { + definition_id, + parameters: parameters_ptr, + parameters_len, + }); + } +} + +/// Returns signatures for a `MethodAliasDefinition` by following the alias chain. +/// Always returns a valid `SignatureArray` pointer (possibly with `len == 0`). +/// Errors during alias resolution (unresolved receivers, circular chains, etc.) +/// are silently ignored. +/// +/// # Safety +/// - `pointer` must be a valid pointer previously returned by `rdx_graph_new`. +/// - `definition_id` must be a valid definition id. +/// +/// # Panics +/// Panics if `definition_id` does not exist or is not a `MethodAliasDefinition`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_method_alias_definition_signatures( + pointer: GraphPointer, + definition_id: u64, +) -> *mut SignatureArray { + with_graph(pointer, |graph| { + let def_id = DefinitionId::new(definition_id); + + let resolved = rubydex::query::deep_dealias_method(graph, def_id); + + let mut sig_entries: Vec = Vec::new(); + for id in &resolved { + if let Some(Definition::Method(method_def)) = graph.definitions().get(id) { + collect_method_signatures(graph, method_def, id.get(), &mut sig_entries); + } + } + + let mut boxed = sig_entries.into_boxed_slice(); + let len = boxed.len(); + let items_ptr = boxed.as_mut_ptr(); + std::mem::forget(boxed); + + Box::into_raw(Box::new(SignatureArray { items: items_ptr, len })) + }) +} + +/// Frees a `SignatureArray` previously returned by `rdx_definition_signatures`. +/// +/// # Safety +/// - `ptr` must be a valid pointer previously returned by `rdx_definition_signatures`. +/// - `ptr` must not be used after being freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_definition_signatures_free(ptr: *mut SignatureArray) { + if ptr.is_null() { + return; + } + + // Take ownership of the SignatureArray + let arr = unsafe { Box::from_raw(ptr) }; + + if !arr.items.is_null() && arr.len > 0 { + // Reconstruct the boxed slice so we can drop it after freeing inner allocations + let slice_ptr = ptr::slice_from_raw_parts_mut(arr.items, arr.len); + let mut sig_slice: Box<[SignatureEntry]> = unsafe { Box::from_raw(slice_ptr) }; + + for sig_entry in &mut sig_slice { + if !sig_entry.parameters.is_null() && sig_entry.parameters_len > 0 { + let param_slice_ptr = ptr::slice_from_raw_parts_mut(sig_entry.parameters, sig_entry.parameters_len); + let mut param_slice: Box<[ParameterEntry]> = unsafe { Box::from_raw(param_slice_ptr) }; + + for param_entry in &mut param_slice { + if !param_entry.name.is_null() { + let _ = unsafe { CString::from_raw(param_entry.name.cast_mut()) }; + } + if !param_entry.location.is_null() { + unsafe { crate::location_api::rdx_location_free(param_entry.location) }; + param_entry.location = ptr::null_mut(); + } + } + // param_slice is dropped here, freeing the parameters buffer + } + } + // sig_slice is dropped here, freeing the signatures buffer + } + // arr is dropped here +} diff --git a/rust/rubydex/src/model/definitions.rs b/rust/rubydex/src/model/definitions.rs index 449ce8c3..974be5dc 100644 --- a/rust/rubydex/src/model/definitions.rs +++ b/rust/rubydex/src/model/definitions.rs @@ -1024,6 +1024,23 @@ pub enum Parameter { } assert_mem_size!(Parameter, 24); +impl Parameter { + #[must_use] + pub fn inner(&self) -> &ParameterStruct { + match self { + Parameter::RequiredPositional(s) + | Parameter::OptionalPositional(s) + | Parameter::RestPositional(s) + | Parameter::Post(s) + | Parameter::RequiredKeyword(s) + | Parameter::OptionalKeyword(s) + | Parameter::RestKeyword(s) + | Parameter::Forward(s) + | Parameter::Block(s) => s, + } + } +} + #[derive(Debug, Clone)] pub struct ParameterStruct { offset: Offset, diff --git a/rust/rubydex/src/offset.rs b/rust/rubydex/src/offset.rs index 7bef5199..a07cd027 100644 --- a/rust/rubydex/src/offset.rs +++ b/rust/rubydex/src/offset.rs @@ -74,6 +74,12 @@ impl Offset { self.end } + /// Returns the source text slice corresponding to this offset. + #[must_use] + pub fn source_at<'a>(&self, source: &'a str) -> &'a str { + &source[self.start as usize..self.end as usize] + } + /// Converts an offset to a display range like `1:1-1:5` #[must_use] pub fn to_display_range(&self, document: &Document) -> String { diff --git a/rust/rubydex/src/query.rs b/rust/rubydex/src/query.rs index 7c5c0fe2..820eced0 100644 --- a/rust/rubydex/src/query.rs +++ b/rust/rubydex/src/query.rs @@ -9,7 +9,7 @@ use crate::model::declaration::{Ancestor, Declaration}; use crate::model::definitions::{Definition, Parameter}; use crate::model::graph::{Graph, OBJECT_ID}; use crate::model::identity_maps::IdentityHashSet; -use crate::model::ids::{DeclarationId, NameId, StringId, UriId}; +use crate::model::ids::{DeclarationId, DefinitionId, NameId, StringId, UriId}; use crate::model::keywords::{self, Keyword}; use crate::model::name::NameRef; @@ -469,6 +469,144 @@ fn method_argument_completion<'a>( Ok(candidates) } +/// Result of dealiasing a single level of method alias. +#[derive(Debug)] +pub enum DealiasMethodResult { + /// The alias target is a concrete method definition. + Method(DefinitionId), + /// The alias target is another alias. + Alias(DefinitionId), +} + +/// Dereferences a `MethodAliasDefinition` by one level, returning the definitions +/// found under the aliased name. Returns `None` if the aliased method name is not +/// found in the namespace or its ancestors. +/// +/// # Panics +/// +/// Panics if `alias_id` does not correspond to a `MethodAliasDefinition` +/// or has no corresponding declaration in the graph. +#[must_use] +pub fn dealias_method(graph: &Graph, alias_id: DefinitionId) -> Option> { + let Definition::MethodAlias(alias) = graph.definitions().get(&alias_id).expect("definition should exist") else { + panic!("expected a method alias definition"); + }; + + let decl_id = graph.definition_id_to_declaration_id(alias_id).unwrap(); + let alias_decl = graph.declarations().get(decl_id).unwrap(); + let owner_id = *alias_decl.owner_id(); + + let method_decl_id = find_member_in_ancestors(graph, owner_id, *alias.old_name_str_id(), false)?; + let method_decl = graph.declarations().get(&method_decl_id).unwrap(); + assert!(matches!(method_decl, Declaration::Method(_))); + + Some( + method_decl + .definitions() + .iter() + .filter_map(|def_id| match graph.definitions().get(def_id) { + Some(Definition::Method(_)) => Some(DealiasMethodResult::Method(*def_id)), + Some(Definition::MethodAlias(_)) => Some(DealiasMethodResult::Alias(*def_id)), + _ => None, + }) + .collect(), + ) +} + +/// Recursively follows alias chains starting from `alias_id`, collecting all +/// resolved `MethodDefinition` IDs. Unlike `dealias_method` which resolves one +/// level, this function keeps following aliases until all chains are fully resolved. +/// Circular aliases and missing targets are silently ignored. +/// +/// # Panics +/// +/// Panics if any alias definition in the chain has no corresponding declaration. +#[must_use] +pub fn deep_dealias_method(graph: &Graph, alias_id: DefinitionId) -> Vec { + let mut method_ids = Vec::new(); + + let mut current_dealias_results = vec![DealiasMethodResult::Alias(alias_id)]; + let mut visited = HashSet::new(); + + loop { + let mut next_aliases = Vec::new(); + + for item in ¤t_dealias_results { + match item { + DealiasMethodResult::Method(id) => { + method_ids.push(*id); + } + DealiasMethodResult::Alias(id) => { + if !visited.insert(*id) { + continue; + } + if let Some(next) = dealias_method(graph, *id) { + next_aliases.extend(next); + } + } + } + } + + if next_aliases.is_empty() { + // Dedup the method ids + let mut seen = HashSet::new(); + method_ids.retain(|id| seen.insert(*id)); + return method_ids; + } + + current_dealias_results = next_aliases; + } +} + +/// Searches for a member by `StringId` in the given namespace's ancestor chain. +/// +/// If `only_inherited` is true, all ancestors up to and including `namespace_id` itself +/// are skipped, so only ancestors after the namespace are searched. This excludes +/// the namespace's own members and any prepended modules. +/// +/// # Panics +/// +/// Panics if `namespace_id` does not exist in declarations, is not a namespace, or +/// if `only_inherited` is true and `namespace_id` is not found in its own ancestor chain. +#[must_use] +pub fn find_member_in_ancestors( + graph: &Graph, + namespace_id: DeclarationId, + str_id: StringId, + only_inherited: bool, +) -> Option { + let ns = graph + .declarations() + .get(&namespace_id) + .expect("namespace_id should exist in declarations") + .as_namespace() + .expect("namespace_id should be a namespace declaration"); + + let ancestors: Vec<_> = ns.ancestors().iter().collect(); + + let search_start = if only_inherited { + let pos = ancestors + .iter() + .position(|a| matches!(a, Ancestor::Complete(id) if *id == namespace_id)) + .expect("namespace_id should be present in its own ancestor chain"); + pos + 1 + } else { + 0 + }; + + for ancestor in &ancestors[search_start..] { + if let Ancestor::Complete(ancestor_id) = ancestor { + let ancestor_decl = graph.declarations().get(ancestor_id).unwrap(); + let ancestor_ns = ancestor_decl.as_namespace().unwrap(); + if let Some(&decl_id) = ancestor_ns.member(&str_id) { + return Some(decl_id); + } + } + } + + None +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -1694,6 +1832,413 @@ mod tests { assert!(!candidates.iter().any(|c| matches!(c, CompletionCandidate::Keyword(_)))); } + macro_rules! assert_dealias_result_source { + ($context:expr, $result:expr, $expected:expr) => { + let def_id = match $result { + DealiasMethodResult::Method(id) | DealiasMethodResult::Alias(id) => id, + }; + assert_eq!( + $context.source_at(def_id), + $expected, + "unexpected source for {:?}", + $result + ); + }; + } + + fn get_method_alias_id(graph: &Graph, decl_name: &str) -> DefinitionId { + let decl = graph.declarations().get(&DeclarationId::from(decl_name)).unwrap(); + for def_id in decl.definitions() { + if matches!(graph.definitions().get(def_id), Some(Definition::MethodAlias(_))) { + return *def_id; + } + } + panic!("No MethodAliasDefinition found for {decl_name}"); + } + + #[test] + fn dealias_method_basic() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Foo + def foo(a, b); end + alias bar foo + end + ", + ); + context.resolve(); + + let id = get_method_alias_id(context.graph(), "Foo#bar()"); + let results = dealias_method(context.graph(), id).unwrap(); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DealiasMethodResult::Method(_))); + assert_dealias_result_source!(&context, &results[0], "def foo(a, b); end"); + } + + #[test] + fn dealias_method_chained() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Foo + def foo(x); end + alias bar foo + alias baz bar + end + ", + ); + context.resolve(); + + // baz -> bar: one level returns the alias to bar + let id = get_method_alias_id(context.graph(), "Foo#baz()"); + let results = dealias_method(context.graph(), id).unwrap(); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DealiasMethodResult::Alias(_))); + assert_dealias_result_source!(&context, &results[0], "alias bar foo"); + + // bar -> foo: one more level returns the method definition + let id = get_method_alias_id(context.graph(), "Foo#bar()"); + let results = dealias_method(context.graph(), id).unwrap(); + assert_eq!(results.len(), 1); + assert!(matches!(results[0], DealiasMethodResult::Method(_))); + assert_dealias_result_source!(&context, &results[0], "def foo(x); end"); + } + + #[test] + fn dealias_method_unresolved() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Foo + alias bar nonexistent + end + ", + ); + context.resolve(); + + let id = get_method_alias_id(context.graph(), "Foo#bar()"); + let result = dealias_method(context.graph(), id); + assert!(result.is_none()); + } + + #[test] + fn dealias_method_multiple_definitions() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Foo + def foo(a); end + end + ", + ); + context.index_uri( + "file:///foo2.rb", + " + class Foo + alias foo baz # another alias definition of #foo() + alias bar foo + end + ", + ); + context.resolve(); + + let id = get_method_alias_id(context.graph(), "Foo#bar()"); + let results = dealias_method(context.graph(), id).unwrap(); + assert_eq!(results.len(), 2); + + let method = results + .iter() + .find(|r| matches!(r, DealiasMethodResult::Method(_))) + .unwrap(); + assert_dealias_result_source!(&context, method, "def foo(a); end"); + + let alias = results + .iter() + .find(|r| matches!(r, DealiasMethodResult::Alias(_))) + .unwrap(); + assert_dealias_result_source!(&context, alias, "alias foo baz"); + } + + #[test] + fn dealias_method_inherited() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Parent + def foo(a); end + end + class Child < Parent + alias bar foo + end + ", + ); + context.resolve(); + + let id = get_method_alias_id(context.graph(), "Child#bar()"); + let results = dealias_method(context.graph(), id).unwrap(); + assert_eq!(results.len(), 1); + assert_dealias_result_source!(&context, &results[0], "def foo(a); end"); + } + + #[test] + fn deep_dealias_method_mixed() { + let mut context = GraphTest::new(); + // `then` has three definitions: + // - a Method (from file1) + // - ignored: an alias to `baz` which is circular (from file2) + // - ignored: an alias to `nonexistent` which is unresolved (from file2) + // `start` aliases `then`, so deep_dealias_method(start) sees all three. + context.index_uri( + "file:///foo1.rb", + " + class Foo + def then(a); end + alias bar baz + alias baz bar + end + ", + ); + context.index_uri( + "file:///foo2.rb", + " + class Foo + alias then baz + alias then nonexistent + alias start then + end + ", + ); + context.resolve(); + + let id = get_method_alias_id(context.graph(), "Foo#start()"); + let method_ids = deep_dealias_method(context.graph(), id); + + // Method definition of `then` is found + assert_eq!(method_ids.len(), 1); + assert_eq!(context.source_at(&method_ids[0]), "def then(a); end"); + } + + #[test] + fn deep_dealias_method_duplicated() { + let mut context = GraphTest::new(); + // `then` has two identical method definitions in different files. Both should be returned by deep_dealias_method. + context.index_uri( + "file:///foo1.rb", + " + class Foo + def foo(a); end + end + ", + ); + context.index_uri( + "file:///foo2.rb", + " + class Foo + alias bar foo + alias bar foo + + alias start bar + end + ", + ); + context.resolve(); + + let id = get_method_alias_id(context.graph(), "Foo#start()"); + let method_ids = deep_dealias_method(context.graph(), id); + + assert_eq!(method_ids.len(), 1); + assert_eq!(context.source_at(&method_ids[0]), "def foo(a); end"); + } + + #[test] + fn find_member_in_ancestors_direct() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Foo + def bar; end + end + ", + ); + context.resolve(); + + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Foo"), + StringId::from("bar()"), + false, + ); + assert_eq!(result, Some(DeclarationId::from("Foo#bar()"))); + } + + #[test] + fn find_member_in_ancestors_inherited() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Parent + def foo; end + end + class Child < Parent + end + ", + ); + context.resolve(); + + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Child"), + StringId::from("foo()"), + false, + ); + assert_eq!(result, Some(DeclarationId::from("Parent#foo()"))); + } + + #[test] + fn find_member_in_ancestors_overridden() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Parent + def foo; end + end + class Child < Parent + def foo; end + end + ", + ); + context.resolve(); + + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Child"), + StringId::from("foo()"), + false, + ); + assert_eq!(result, Some(DeclarationId::from("Child#foo()"))); + } + + #[test] + fn find_member_in_ancestors_not_found() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Foo + end + ", + ); + context.resolve(); + + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Foo"), + StringId::from("nonexistent()"), + false, + ); + assert_eq!(result, None); + } + + #[test] + fn find_member_in_ancestors_only_inherited() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + class Parent + def foo; end + end + class Child < Parent + def foo; end + def bar; end + end + ", + ); + context.resolve(); + + // own method is skipped with only_inherited + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Child"), + StringId::from("foo()"), + true, + ); + assert_eq!(result, Some(DeclarationId::from("Parent#foo()"))); + + // method only in self returns None with only_inherited + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Child"), + StringId::from("bar()"), + true, + ); + assert_eq!(result, None); + } + + #[test] + fn find_member_in_ancestors_only_inherited_with_prepend() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + module M + def foo; end + end + class Parent + def foo; end + end + class Child < Parent + prepend M + def foo; end + end + ", + ); + context.resolve(); + + // prepended module and self are skipped, finds Parent's foo + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Child"), + StringId::from("foo()"), + true, + ); + assert_eq!(result, Some(DeclarationId::from("Parent#foo()"))); + } + + #[test] + fn find_member_in_ancestors_via_module() { + let mut context = GraphTest::new(); + context.index_uri( + "file:///foo.rb", + " + module Greetable + def greet; end + end + class Foo + include Greetable + end + ", + ); + context.resolve(); + + let result = find_member_in_ancestors( + context.graph(), + DeclarationId::from("Foo"), + StringId::from("greet()"), + false, + ); + assert_eq!(result, Some(DeclarationId::from("Greetable#greet()"))); + } + #[test] fn method_call_completion_excludes_keywords() { let mut context = GraphTest::new(); diff --git a/rust/rubydex/src/test_utils/graph_test.rs b/rust/rubydex/src/test_utils/graph_test.rs index fb2766fd..fdf3fd36 100644 --- a/rust/rubydex/src/test_utils/graph_test.rs +++ b/rust/rubydex/src/test_utils/graph_test.rs @@ -1,20 +1,26 @@ +use std::collections::HashMap; + use super::normalize_indentation; #[cfg(test)] use crate::diagnostic::Rule; use crate::indexing::{self, LanguageId}; use crate::model::graph::{Graph, NameDependent}; -use crate::model::ids::{NameId, StringId}; +use crate::model::ids::{DefinitionId, NameId, StringId}; use crate::resolution::Resolver; #[derive(Default)] pub struct GraphTest { graph: Graph, + sources: HashMap, } impl GraphTest { #[must_use] pub fn new() -> Self { - Self { graph: Graph::new() } + Self { + graph: Graph::new(), + sources: HashMap::new(), + } } #[must_use] @@ -31,12 +37,36 @@ impl GraphTest { pub fn index_uri(&mut self, uri: &str, source: &str) { let source = normalize_indentation(source); indexing::index_source(&mut self.graph, uri, &source, &LanguageId::Ruby); + self.sources.insert(uri.to_string(), source); } /// Indexes an RBS source pub fn index_rbs_uri(&mut self, uri: &str, source: &str) { let source = normalize_indentation(source); indexing::index_source(&mut self.graph, uri, &source, &LanguageId::Rbs); + self.sources.insert(uri.to_string(), source); + } + + /// Returns the normalized source for the given URI. + /// + /// # Panics + /// + /// Panics if the URI has not been indexed. + #[must_use] + pub fn source(&self, uri: &str) -> &str { + self.sources.get(uri).expect("source not found for URI") + } + + /// Returns the source text for a definition, sliced by its offset. + /// + /// # Panics + /// + /// Panics if the definition or its document does not exist. + #[must_use] + pub fn source_at(&self, definition_id: &DefinitionId) -> &str { + let def = self.graph.definitions().get(definition_id).unwrap(); + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + def.offset().source_at(self.source(uri)) } pub fn delete_uri(&mut self, uri: &str) { diff --git a/test/definition_test.rb b/test/definition_test.rb index 2389bf12..a1704a3b 100644 --- a/test/definition_test.rb +++ b/test/definition_test.rb @@ -364,6 +364,322 @@ class << self end end + def test_method_definition_signatures_from_ruby + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + def foo(x) = x + + def self.bar(y) = y + end + RUBY + + path = context.absolute_path_to("file1.rb") + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + graph["Foo#foo()"].definitions.flat_map(&:signatures).tap do |signatures| + assert_equal(1, signatures.size) + signatures[0].parameters[0].tap do |param| + assert_instance_of(Rubydex::Signature::PositionalParameter, param) + assert_equal(:x, param.name) + assert_equal("#{path}:2:11-2:12", param.location.to_display.to_s) # a + end + end + + graph["Foo::#bar()"].definitions.flat_map(&:signatures).tap do |signatures| + assert_equal(1, signatures.size) + signatures[0].parameters[0].tap do |param| + assert_instance_of(Rubydex::Signature::PositionalParameter, param) + assert_equal(:y, param.name) + assert_equal("#{path}:4:16-4:17", param.location.to_display.to_s) # a + end + end + end + end + + def test_method_definition_signatures_with_various_parameter_kinds + with_context do |context| + context.write!("file1.rb", <<~RUBY) + def foo(a, b = 1, *c, d, e:, f: 1, **g, &h); end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + method_def = graph["Object#foo()"].definitions.first + refute_nil(method_def) + + signatures = method_def.signatures + assert_equal(1, signatures.length) + + sig = signatures.first + assert_instance_of(Rubydex::Signature, sig) + + params = sig.parameters + assert_equal(8, params.length) + + path = context.absolute_path_to("file1.rb") + + assert_instance_of(Rubydex::Signature::PositionalParameter, params[0]) + assert_equal(:a, params[0].name) + assert_equal("#{path}:1:9-1:10", params[0].location.to_display.to_s) # a + + assert_instance_of(Rubydex::Signature::OptionalPositionalParameter, params[1]) + assert_equal(:b, params[1].name) + assert_equal("#{path}:1:12-1:13", params[1].location.to_display.to_s) # b + + assert_instance_of(Rubydex::Signature::RestPositionalParameter, params[2]) + assert_equal(:c, params[2].name) + assert_equal("#{path}:1:20-1:21", params[2].location.to_display.to_s) # c + + assert_instance_of(Rubydex::Signature::PostParameter, params[3]) + assert_equal(:d, params[3].name) + assert_equal("#{path}:1:23-1:24", params[3].location.to_display.to_s) # d + + assert_instance_of(Rubydex::Signature::KeywordParameter, params[4]) + assert_equal(:e, params[4].name) + assert_equal("#{path}:1:26-1:27", params[4].location.to_display.to_s) # e + + assert_instance_of(Rubydex::Signature::OptionalKeywordParameter, params[5]) + assert_equal(:f, params[5].name) + assert_equal("#{path}:1:30-1:31", params[5].location.to_display.to_s) # f + + assert_instance_of(Rubydex::Signature::RestKeywordParameter, params[6]) + assert_equal(:g, params[6].name) + assert_equal("#{path}:1:38-1:39", params[6].location.to_display.to_s) # g + + assert_instance_of(Rubydex::Signature::BlockParameter, params[7]) + assert_equal(:h, params[7].name) + assert_equal("#{path}:1:42-1:43", params[7].location.to_display.to_s) # h + end + end + + def test_method_definition_signatures_no_parameters + with_context do |context| + context.write!("file1.rb", <<~RUBY) + def bar; end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + method_def = graph["Object#bar()"].definitions.first + refute_nil(method_def) + + signatures = method_def.signatures + assert_equal(1, signatures.length) + assert_empty(signatures.first.parameters) + end + end + + def test_method_definition_signatures_forward + with_context do |context| + context.write!("file1.rb", <<~RUBY) + def baz(...); end + RUBY + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + method_def = graph["Object#baz()"].definitions.first + refute_nil(method_def) + + path = context.absolute_path_to("file1.rb") + params = method_def.signatures.first.parameters + assert_equal(1, params.length) + assert_instance_of(Rubydex::Signature::ForwardParameter, params[0]) + assert_equal(:"...", params[0].name) + assert_equal("#{path}:1:9-1:12", params[0].location.to_display.to_s) + end + end + + def test_method_definition_signatures_from_rbs + with_context do |context| + context.write!("foo.rbs", <<~RBS) + class Foo + def bar: (String a, ?String b, *String c, String d, name: String, ?mode: String, **String opts) { (String) -> void } -> void + end + RBS + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rbs")) + graph.resolve + + method_def = graph["Foo#bar()"].definitions.first + refute_nil(method_def) + + signatures = method_def.signatures + assert_equal(1, signatures.length) + + params = signatures.first.parameters + assert_equal(8, params.length) + + path = context.absolute_path_to("foo.rbs") + + assert_instance_of(Rubydex::Signature::PositionalParameter, params[0]) + assert_equal(:a, params[0].name) + assert_equal("#{path}:2:20-2:21", params[0].location.to_display.to_s) # a + assert_instance_of(Rubydex::Signature::OptionalPositionalParameter, params[1]) + assert_equal(:b, params[1].name) + assert_equal("#{path}:2:31-2:32", params[1].location.to_display.to_s) # b + assert_instance_of(Rubydex::Signature::RestPositionalParameter, params[2]) + assert_equal(:c, params[2].name) + assert_equal("#{path}:2:42-2:43", params[2].location.to_display.to_s) # c + assert_instance_of(Rubydex::Signature::PostParameter, params[3]) + assert_equal(:d, params[3].name) + assert_equal("#{path}:2:52-2:53", params[3].location.to_display.to_s) # d + assert_instance_of(Rubydex::Signature::KeywordParameter, params[4]) + assert_equal(:name, params[4].name) + assert_equal("#{path}:2:55-2:59", params[4].location.to_display.to_s) # name + assert_instance_of(Rubydex::Signature::OptionalKeywordParameter, params[5]) + assert_equal(:mode, params[5].name) + assert_equal("#{path}:2:70-2:74", params[5].location.to_display.to_s) # mode + assert_instance_of(Rubydex::Signature::RestKeywordParameter, params[6]) + assert_equal(:opts, params[6].name) + assert_equal("#{path}:2:93-2:97", params[6].location.to_display.to_s) # opts + assert_instance_of(Rubydex::Signature::BlockParameter, params[7]) + assert_equal(:block, params[7].name) + assert_equal("#{path}:2:99-2:119", params[7].location.to_display.to_s) # { (String) -> void } + end + end + + def test_method_definition_signatures_from_rbs_with_untyped_parameters + with_context do |context| + context.write!("foo.rbs", <<~RBS) + class Foo + def baz: (?) -> void + end + RBS + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rbs")) + graph.resolve + + method_def = graph["Foo#baz()"].definitions.first + refute_nil(method_def) + + signatures = method_def.signatures + assert_equal(1, signatures.length) + assert_empty(signatures.first.parameters) + end + end + + def test_method_definition_signatures_from_rbs_with_overloads + with_context do |context| + context.write!("foo.rbs", <<~RBS) + class Foo + def bar: (String name) -> void + | (Integer id, ?Symbol mode) -> String + end + RBS + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rbs")) + graph.resolve + + method_def = graph["Foo#bar()"].definitions.first + refute_nil(method_def) + + signatures = method_def.signatures + assert_equal(2, signatures.length) + + path = context.absolute_path_to("foo.rbs") + + params0 = signatures[0].parameters + assert_equal(1, params0.length) + assert_instance_of(Rubydex::Signature::PositionalParameter, params0[0]) + assert_equal(:name, params0[0].name) + assert_equal("#{path}:2:20-2:24", params0[0].location.to_display.to_s) # name + + params1 = signatures[1].parameters + assert_equal(2, params1.length) + assert_instance_of(Rubydex::Signature::PositionalParameter, params1[0]) + assert_equal(:id, params1[0].name) + assert_equal("#{path}:3:21-3:23", params1[0].location.to_display.to_s) # id + assert_instance_of(Rubydex::Signature::OptionalPositionalParameter, params1[1]) + assert_equal(:mode, params1[1].name) + assert_equal("#{path}:3:33-3:37", params1[1].location.to_display.to_s) # mode + end + end + + def test_method_alias_definition_signatures + 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) + + signatures = alias_def.signatures + assert_equal(1, signatures.length) + + params = signatures.first.parameters + assert_equal(2, params.length) + assert_equal(:a, params[0].name) + assert_equal(:b, params[1].name) + end + end + + def test_method_alias_definition_signatures_chained + with_context do |context| + context.write!("file1.rb", <<~RUBY) + class Foo + def foo(x); 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) + + signatures = alias_def.signatures + assert_equal(1, signatures.length) + + params = signatures.first.parameters + assert_equal(1, params.length) + assert_equal(:x, params[0].name) + end + end + + def test_method_alias_definition_signatures_unresolved + 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) + + signatures = alias_def.signatures + assert_empty(signatures) + end + end + private # Comment locations on Windows include the carriage return. This means that the end column is off by one when compared