From a64dff2cb6d39558b60fdf874505317a3cb9efe0 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 20 Mar 2026 15:32:19 -0400 Subject: [PATCH 1/3] Add keyword and keyword parameter objects --- lib/rubydex.rb | 2 ++ lib/rubydex/keyword.rb | 17 +++++++++++++++++ lib/rubydex/keyword_parameter.rb | 13 +++++++++++++ rbi/rubydex.rbi | 19 +++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 lib/rubydex/keyword.rb create mode 100644 lib/rubydex/keyword_parameter.rb diff --git a/lib/rubydex.rb b/lib/rubydex.rb index 17d8e9f0..14a974ef 100644 --- a/lib/rubydex.rb +++ b/lib/rubydex.rb @@ -17,4 +17,6 @@ require "rubydex/location" require "rubydex/comment" require "rubydex/diagnostic" +require "rubydex/keyword" +require "rubydex/keyword_parameter" require "rubydex/graph" diff --git a/lib/rubydex/keyword.rb b/lib/rubydex/keyword.rb new file mode 100644 index 00000000..9ae88ee1 --- /dev/null +++ b/lib/rubydex/keyword.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Rubydex + class Keyword + #: String + attr_reader :name + + #: String + attr_reader :documentation + + #: (String name, String documentation) -> void + def initialize(name, documentation) + @name = name + @documentation = documentation + end + end +end diff --git a/lib/rubydex/keyword_parameter.rb b/lib/rubydex/keyword_parameter.rb new file mode 100644 index 00000000..a0f830d5 --- /dev/null +++ b/lib/rubydex/keyword_parameter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Rubydex + class KeywordParameter + #: String + attr_reader :name + + #: (String name) -> void + def initialize(name) + @name = name + end + end +end diff --git a/rbi/rubydex.rbi b/rbi/rubydex.rbi index f5fc65b3..f985ad92 100644 --- a/rbi/rubydex.rbi +++ b/rbi/rubydex.rbi @@ -128,6 +128,25 @@ class Rubydex::Diagnostic def rule; end end +class Rubydex::Keyword + sig { params(name: String, documentation: String).void } + def initialize(name, documentation); end + + sig { returns(String) } + def name; end + + sig { returns(String) } + def documentation; end +end + +class Rubydex::KeywordParameter + sig { params(name: String).void } + def initialize(name); end + + sig { returns(String) } + def name; end +end + class Rubydex::Document sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end From c34ea696b02b29174aab5f3d3521b604bb17d679 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 20 Mar 2026 15:38:29 -0400 Subject: [PATCH 2/3] Expose completion to the Ruby API --- ext/rubydex/graph.c | 120 ++++++++++++++ rbi/rubydex.rbi | 25 +++ rust/rubydex-sys/src/graph_api.rs | 255 +++++++++++++++++++++++++++++- test/graph_test.rb | 115 ++++++++++++++ 4 files changed, 514 insertions(+), 1 deletion(-) diff --git a/ext/rubydex/graph.c b/ext/rubydex/graph.c index a56876c6..b3e3f306 100644 --- a/ext/rubydex/graph.c +++ b/ext/rubydex/graph.c @@ -10,6 +10,8 @@ static VALUE cGraph; static VALUE mRubydex; +static VALUE cKeyword; +static VALUE cKeywordParameter; // Free function for the custom Graph allocator. We always have to call into Rust to free data allocated by it static void graph_free(void *ptr) { @@ -488,9 +490,123 @@ static VALUE rdxr_graph_diagnostics(VALUE self) { return diagnostics; } +// Helper: convert a CompletionCandidateArray into a Ruby array of typed objects and free the C array. +static VALUE completion_candidates_to_ruby_array(CompletionCandidateArray *array, VALUE graph_obj) { + if (array == NULL) { + return rb_ary_new(); + } + + if (array->len == 0) { + rdx_completion_candidates_free(array); + return rb_ary_new(); + } + + VALUE result = rb_ary_new_capa((long)array->len); + + for (size_t i = 0; i < array->len; i++) { + CCompletionCandidate item = array->items[i]; + VALUE obj; + + switch (item.kind) { + case CCompletionCandidateKind_Declaration: { + VALUE decl_class = rdxi_declaration_class_for_kind(item.declaration->kind); + VALUE argv[] = {graph_obj, ULL2NUM(item.declaration->id)}; + obj = rb_class_new_instance(2, argv, decl_class); + break; + } + case CCompletionCandidateKind_Keyword: { + VALUE argv[2] = { + rb_utf8_str_new_cstr(item.name), + rb_utf8_str_new_cstr(item.documentation), + }; + obj = rb_class_new_instance(2, argv, cKeyword); + break; + } + case CCompletionCandidateKind_KeywordParameter: { + VALUE argv[1] = { rb_utf8_str_new_cstr(item.name) }; + obj = rb_class_new_instance(1, argv, cKeywordParameter); + break; + } + default: + rdx_completion_candidates_free(array); + rb_raise(rb_eRuntimeError, "Unknown CCompletionCandidateKind: %d", item.kind); + } + + rb_ary_push(result, obj); + } + + rdx_completion_candidates_free(array); + return result; +} + +// Graph#complete_expression: (Array[String] nesting) -> Array[Declaration | Keyword] +// Returns completion candidates for an expression context. +// The nesting array represents the lexical scope stack +static VALUE rdxr_graph_complete_expression(VALUE self, VALUE nesting) { + rdxi_check_array_of_strings(nesting); + + void *graph; + TypedData_Get_Struct(self, void *, &graph_type, graph); + + size_t nesting_count = RARRAY_LEN(nesting); + char **converted_nesting = rdxi_str_array_to_char(nesting, nesting_count); + + CompletionCandidateArray *results = + rdx_graph_complete_expression(graph, (const char *const *)converted_nesting, nesting_count); + + rdxi_free_str_array(converted_nesting, nesting_count); + return completion_candidates_to_ruby_array(results, self); +} + +// Graph#complete_namespace_access: (String name) -> Array[Declaration] +// Returns completion candidates after a namespace access operator (e.g., `Foo::`). +static VALUE rdxr_graph_complete_namespace_access(VALUE self, VALUE name) { + Check_Type(name, T_STRING); + + void *graph; + TypedData_Get_Struct(self, void *, &graph_type, graph); + + CompletionCandidateArray *results = rdx_graph_complete_namespace_access(graph, StringValueCStr(name)); + return completion_candidates_to_ruby_array(results, self); +} + +// Graph#complete_method_call: (String name) -> Array[Declaration] +// Returns completion candidates after a method call operator (e.g., `foo.`). +static VALUE rdxr_graph_complete_method_call(VALUE self, VALUE name) { + Check_Type(name, T_STRING); + + void *graph; + TypedData_Get_Struct(self, void *, &graph_type, graph); + + CompletionCandidateArray *results = rdx_graph_complete_method_call(graph, StringValueCStr(name)); + return completion_candidates_to_ruby_array(results, self); +} + +// Graph#complete_method_argument: (String name, Array[String] nesting) -> Array[Declaration | Keyword | KeywordParameter] +// Returns completion candidates inside a method call's argument list (e.g., `foo.bar(|)`). +static VALUE rdxr_graph_complete_method_argument(VALUE self, VALUE name, VALUE nesting) { + Check_Type(name, T_STRING); + rdxi_check_array_of_strings(nesting); + + void *graph; + TypedData_Get_Struct(self, void *, &graph_type, graph); + + size_t nesting_count = RARRAY_LEN(nesting); + char **converted_nesting = rdxi_str_array_to_char(nesting, nesting_count); + + CompletionCandidateArray *results = rdx_graph_complete_method_argument( + graph, StringValueCStr(name), (const char *const *)converted_nesting, nesting_count); + + rdxi_free_str_array(converted_nesting, nesting_count); + return completion_candidates_to_ruby_array(results, self); +} + void rdxi_initialize_graph(VALUE moduleRubydex) { mRubydex = moduleRubydex; cGraph = rb_define_class_under(mRubydex, "Graph", rb_cObject); + cKeyword = rb_define_class_under(mRubydex, "Keyword", rb_cObject); + cKeywordParameter = rb_define_class_under(mRubydex, "KeywordParameter", rb_cObject); + rb_define_alloc_func(cGraph, rdxr_graph_alloc); rb_define_method(cGraph, "index_all", rdxr_graph_index_all, 1); rb_define_method(cGraph, "index_source", rdxr_graph_index_source, 3); @@ -509,4 +625,8 @@ void rdxi_initialize_graph(VALUE moduleRubydex) { rb_define_method(cGraph, "encoding=", rdxr_graph_set_encoding, 1); rb_define_method(cGraph, "resolve_require_path", rdxr_graph_resolve_require_path, 2); rb_define_method(cGraph, "require_paths", rdxr_graph_require_paths, 1); + rb_define_method(cGraph, "complete_expression", rdxr_graph_complete_expression, 1); + rb_define_method(cGraph, "complete_namespace_access", rdxr_graph_complete_namespace_access, 1); + rb_define_method(cGraph, "complete_method_call", rdxr_graph_complete_method_call, 1); + rb_define_method(cGraph, "complete_method_argument", rdxr_graph_complete_method_argument, 2); } diff --git a/rbi/rubydex.rbi b/rbi/rubydex.rbi index f985ad92..ff448995 100644 --- a/rbi/rubydex.rbi +++ b/rbi/rubydex.rbi @@ -243,6 +243,31 @@ class Rubydex::Graph sig { returns(T::Array[Rubydex::Failure]) } def check_integrity; end + # Returns completion candidates for an expression context. This includes all keywords, constants, methods, instance + # variables, class variables and global variables reachable from the current lexical scope and self type. + # + # The nesting array represents the lexical scope stack, where the last element is the self type. An empty array + # defaults to `Object` as the self type (top-level context). + sig { params(nesting: T::Array[String]).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + def complete_expression(nesting); end + + # Returns completion candidates after a namespace access operator (e.g., `Foo::`). This includes all constants and + # singleton methods for the namespace and its ancestors. + sig { params(name: String).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + def complete_namespace_access(name); end + + # Returns completion candidates after a method call operator (e.g., `foo.`). This includes all methods that exist on + # the type of the receiver and its ancestors. + sig { params(name: String).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + def complete_method_call(name); end + + # Returns completion candidates inside a method call's argument list (e.g., `foo.bar(|)`). This includes everything + # that expression completion provides plus keyword argument names of the method being called. + # + # The nesting array represents the lexical scope stack, where the last element is the self type. + sig { params(name: String, nesting: T::Array[String]).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + def complete_method_argument(name, nesting); end + private sig { params(paths: T::Array[String]).void } diff --git a/rust/rubydex-sys/src/graph_api.rs b/rust/rubydex-sys/src/graph_api.rs index f06b849c..2ef9c689 100644 --- a/rust/rubydex-sys/src/graph_api.rs +++ b/rust/rubydex-sys/src/graph_api.rs @@ -8,7 +8,8 @@ use libc::{c_char, c_void}; use rubydex::indexing::LanguageId; use rubydex::model::encoding::Encoding; use rubydex::model::graph::Graph; -use rubydex::model::ids::DeclarationId; +use rubydex::model::ids::{DeclarationId, NameId}; +use rubydex::query::{CompletionCandidate, CompletionContext, CompletionReceiver}; use rubydex::resolution::Resolver; use rubydex::{indexing, integrity, listing, query}; use std::ffi::CString; @@ -624,6 +625,258 @@ pub unsafe extern "C" fn rdx_index_source( }) } +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub enum CCompletionCandidateKind { + Declaration = 0, + Keyword = 1, + KeywordParameter = 2, +} + +#[repr(C)] +pub struct CCompletionCandidate { + pub kind: CCompletionCandidateKind, + /// Only valid when `kind == Declaration`; null otherwise. + pub declaration: *const CDeclaration, + pub name: *const c_char, + pub documentation: *const c_char, +} + +#[repr(C)] +pub struct CompletionCandidateArray { + pub items: *mut CCompletionCandidate, + pub len: usize, +} + +impl CompletionCandidateArray { + fn from_vec(entries: Vec) -> *mut CompletionCandidateArray { + let mut boxed = entries.into_boxed_slice(); + let len = boxed.len(); + let ptr = boxed.as_mut_ptr(); + mem::forget(boxed); + Box::into_raw(Box::new(CompletionCandidateArray { items: ptr, len })) + } +} + +/// Frees a completion candidate array previously returned by a completion function. +/// +/// # Safety +/// +/// - `ptr` must be a valid pointer previously returned by a completion function. +/// - `ptr` must not be used after being freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_completion_candidates_free(ptr: *mut CompletionCandidateArray) { + if ptr.is_null() { + return; + } + + let array = unsafe { Box::from_raw(ptr) }; + + if !array.items.is_null() && array.len > 0 { + let slice_ptr = ptr::slice_from_raw_parts_mut(array.items, array.len); + let mut boxed_slice: Box<[CCompletionCandidate]> = unsafe { Box::from_raw(slice_ptr) }; + + for entry in &mut *boxed_slice { + if !entry.declaration.is_null() { + let _ = unsafe { Box::from_raw(entry.declaration.cast_mut()) }; + } + if !entry.name.is_null() { + let _ = unsafe { CString::from_raw(entry.name.cast_mut()) }; + } + if !entry.documentation.is_null() { + let _ = unsafe { CString::from_raw(entry.documentation.cast_mut()) }; + } + } + } +} + +/// Converts the nesting stack into a `NameId`. +/// The last element of the nesting stack is treated as the self type; if the stack is empty, `"Object"` is used. +/// +/// Returns `Err` if the nesting array contains invalid UTF-8. +/// +/// # Safety +/// +/// `nesting` must point to `nesting_count` valid, null-terminated UTF-8 strings. +unsafe fn completion_nesting_name_id( + graph: &mut Graph, + nesting: *const *const c_char, + nesting_count: usize, +) -> Result<(NameId, Vec), std::str::Utf8Error> { + let mut nesting: Vec = unsafe { utils::convert_double_pointer_to_vec(nesting, nesting_count)? }; + + // When serving completion in a bare script, the self (top level) context is Object + let self_name = if nesting.is_empty() { + "Object".to_string() + } else { + nesting.pop().unwrap() + }; + + Ok(name_api::nesting_stack_to_name_id(graph, &self_name, nesting)) +} + +/// Runs completion for the given receiver and returns a structured array of candidates +fn run_and_finalize_completion( + graph: &mut Graph, + receiver: CompletionReceiver, + names_to_untrack: Vec, +) -> *mut CompletionCandidateArray { + let Ok(candidates) = query::completion_candidates(graph, CompletionContext::new(receiver)) else { + for name_id in names_to_untrack { + graph.untrack_name(name_id); + } + return ptr::null_mut(); + }; + + let entries: Vec = candidates + .into_iter() + .filter_map(|candidate| { + Some(match candidate { + CompletionCandidate::Declaration(id) => { + let decl = graph.declarations().get(&id)?; + CCompletionCandidate { + kind: CCompletionCandidateKind::Declaration, + declaration: Box::into_raw(Box::new(CDeclaration::from_declaration(id, decl))), + name: ptr::null(), + documentation: ptr::null(), + } + } + CompletionCandidate::Keyword(kw) => CCompletionCandidate { + kind: CCompletionCandidateKind::Keyword, + declaration: ptr::null(), + name: CString::new(kw.name()).ok()?.into_raw().cast_const(), + documentation: CString::new(kw.documentation()).ok()?.into_raw().cast_const(), + }, + CompletionCandidate::KeywordArgument(str_id) => { + let name_str = graph.strings().get(&str_id)?; + CCompletionCandidate { + kind: CCompletionCandidateKind::KeywordParameter, + declaration: ptr::null(), + name: CString::new(name_str.as_str()).ok()?.into_raw().cast_const(), + documentation: ptr::null(), + } + } + }) + }) + .collect(); + + for name_id in names_to_untrack { + graph.untrack_name(name_id); + } + + CompletionCandidateArray::from_vec(entries) +} + +/// Returns expression completion candidates. +/// The caller must free the result with `rdx_completion_candidates_free`. +/// +/// # Safety +/// +/// - `pointer` must be a valid `GraphPointer` previously returned by this crate. +/// - `nesting` must point to `nesting_count` valid, null-terminated UTF-8 strings. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_graph_complete_expression( + pointer: GraphPointer, + nesting: *const *const c_char, + nesting_count: usize, +) -> *mut CompletionCandidateArray { + with_mut_graph(pointer, |graph| { + let Ok((name_id, names_to_untrack)) = (unsafe { completion_nesting_name_id(graph, nesting, nesting_count) }) + else { + return ptr::null_mut(); + }; + + run_and_finalize_completion(graph, CompletionReceiver::Expression(name_id), names_to_untrack) + }) +} + +/// Returns namespace access completion candidates. +/// The caller must free the result with `rdx_completion_candidates_free`. +/// +/// # Safety +/// +/// - `pointer` must be a valid `GraphPointer` previously returned by this crate. +/// - `name` must be a valid, null-terminated UTF-8 string (FQN of the namespace). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_graph_complete_namespace_access( + pointer: GraphPointer, + name: *const c_char, +) -> *mut CompletionCandidateArray { + let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { + return ptr::null_mut(); + }; + + with_mut_graph(pointer, |graph| { + run_and_finalize_completion( + graph, + CompletionReceiver::NamespaceAccess(DeclarationId::from(name_str.as_str())), + Vec::new(), + ) + }) +} + +/// Returns method call completion candidates. +/// The caller must free the result with `rdx_completion_candidates_free`. +/// +/// # Safety +/// +/// - `pointer` must be a valid `GraphPointer` previously returned by this crate. +/// - `name` must be a valid, null-terminated UTF-8 string (FQN of the receiver). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_graph_complete_method_call( + pointer: GraphPointer, + name: *const c_char, +) -> *mut CompletionCandidateArray { + let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { + return ptr::null_mut(); + }; + + with_mut_graph(pointer, |graph| { + run_and_finalize_completion( + graph, + CompletionReceiver::MethodCall(DeclarationId::from(name_str.as_str())), + Vec::new(), + ) + }) +} + +/// Returns method argument completion candidates. +/// The caller must free the result with `rdx_completion_candidates_free`. +/// +/// # Safety +/// +/// - `pointer` must be a valid `GraphPointer` previously returned by this crate. +/// - `name` must be a valid, null-terminated UTF-8 string (FQN of the method). +/// - `nesting` must point to `nesting_count` valid, null-terminated UTF-8 strings. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_graph_complete_method_argument( + pointer: GraphPointer, + name: *const c_char, + nesting: *const *const c_char, + nesting_count: usize, +) -> *mut CompletionCandidateArray { + let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { + return ptr::null_mut(); + }; + + with_mut_graph(pointer, |graph| { + let Ok((self_name_id, names_to_untrack)) = + (unsafe { completion_nesting_name_id(graph, nesting, nesting_count) }) + else { + return ptr::null_mut(); + }; + + run_and_finalize_completion( + graph, + CompletionReceiver::MethodArgument { + self_name_id, + method_decl_id: DeclarationId::from(name_str.as_str()), + }, + names_to_untrack, + ) + }) +} + #[cfg(test)] mod tests { use rubydex::indexing::ruby_indexer::RubyIndexer; diff --git a/test/graph_test.rb b/test/graph_test.rb index 081bbeeb..1ec0deaa 100644 --- a/test/graph_test.rb +++ b/test/graph_test.rb @@ -610,6 +610,121 @@ class Foo end end + def test_complete_expression + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", "class Foo\n CONST = 1\n def bar; end\nend", "ruby") + graph.resolve + + candidates = graph.complete_expression(["Foo"]) + + # Declaration candidates + constants = candidates.select { |c| c.is_a?(Rubydex::Constant) } + assert(constants.any? { |c| c.name == "Foo::CONST" }) + + methods = candidates.select { |c| c.is_a?(Rubydex::Method) } + assert(methods.any? { |c| c.name == "Foo#bar()" }) + + # Keyword candidates + keywords = candidates.select { |c| c.is_a?(Rubydex::Keyword) } + keyword_names = keywords.map(&:name) + assert_includes(keyword_names, "if") + assert_includes(keyword_names, "yield") + + # Keywords have documentation + if_keyword = keywords.find { |c| c.name == "if" } + refute_nil(if_keyword) + assert_kind_of(String, if_keyword.documentation) + refute_empty(if_keyword.documentation) + end + + def test_complete_namespace_access + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", <<~RUBY, "ruby") + class Foo + CONST = 1 + + class << self + def bar; end + end + end + RUBY + graph.resolve + + candidates = graph.complete_namespace_access("Foo") + + # All candidates should be Declaration subclasses (no keywords) + candidates.each { |c| assert_kind_of(Rubydex::Declaration, c) } + + assert(candidates.any? { |c| c.is_a?(Rubydex::Constant) && c.name == "Foo::CONST" }) + assert(candidates.any? { |c| c.is_a?(Rubydex::Method) && c.name == "Foo::#bar()" }) + end + + def test_complete_method_call + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", "class Foo\n def bar; end\n def baz; end\nend", "ruby") + graph.resolve + + candidates = graph.complete_method_call("Foo") + + # All candidates should be Method instances + candidates.each { |c| assert_kind_of(Rubydex::Method, c) } + + method_names = candidates.map(&:name) + assert_includes(method_names, "Foo#bar()") + assert_includes(method_names, "Foo#baz()") + end + + def test_complete_method_argument + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", "class Foo\n def bar(name:); end\nend", "ruby") + graph.resolve + + candidates = graph.complete_method_argument("Foo#bar()", ["Foo"]) + + # Method candidates + methods = candidates.select { |c| c.is_a?(Rubydex::Method) } + assert(methods.any? { |c| c.name == "Foo#bar()" }) + + # Keyword candidates + keywords = candidates.select { |c| c.is_a?(Rubydex::Keyword) } + assert(keywords.any? { |c| c.name == "if" }) + + # KeywordParameter candidates + keyword_params = candidates.select { |c| c.is_a?(Rubydex::KeywordParameter) } + assert(keyword_params.any? { |c| c.name == "name:" }) + end + + def test_complete_expression_raises_with_wrong_types + graph = Rubydex::Graph.new + assert_raises(TypeError) { graph.complete_expression("not an array") } + assert_raises(TypeError) { graph.complete_expression([123]) } + end + + def test_complete_namespace_access_raises_with_wrong_types + graph = Rubydex::Graph.new + assert_raises(TypeError) { graph.complete_namespace_access(123) } + end + + def test_complete_method_call_raises_with_wrong_types + graph = Rubydex::Graph.new + assert_raises(TypeError) { graph.complete_method_call(123) } + end + + def test_complete_method_argument_raises_with_wrong_types + graph = Rubydex::Graph.new + assert_raises(TypeError) { graph.complete_method_argument(123, []) } + assert_raises(TypeError) { graph.complete_method_argument("Foo#bar()", "not an array") } + assert_raises(TypeError) { graph.complete_method_argument("Foo#bar()", [123]) } + end + + def test_completion_returns_empty_for_non_existent_declarations + graph = Rubydex::Graph.new + graph.resolve + + assert_equal([], graph.complete_namespace_access("DoesNotExist")) + assert_equal([], graph.complete_method_call("DoesNotExist")) + end + private def assert_diagnostics(expected, actual) From bcc8e8643e53c2c926c0701d5fe43004c6e7461c Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 27 Mar 2026 16:43:06 -0400 Subject: [PATCH 3/3] Raise completion errors --- ext/rubydex/graph.c | 33 +++++--- rbi/rubydex.rbi | 6 +- rust/rubydex-sys/src/graph_api.rs | 135 ++++++++++++++++++++---------- rust/rubydex/src/query.rs | 37 +++++--- test/graph_test.rb | 97 ++++++++++++++++++++- 5 files changed, 234 insertions(+), 74 deletions(-) diff --git a/ext/rubydex/graph.c b/ext/rubydex/graph.c index b3e3f306..d4929938 100644 --- a/ext/rubydex/graph.c +++ b/ext/rubydex/graph.c @@ -490,8 +490,15 @@ static VALUE rdxr_graph_diagnostics(VALUE self) { return diagnostics; } -// Helper: convert a CompletionCandidateArray into a Ruby array of typed objects and free the C array. -static VALUE completion_candidates_to_ruby_array(CompletionCandidateArray *array, VALUE graph_obj) { +// Helper: convert a CompletionResult into a Ruby array, raising ArgumentError on error. +static VALUE completion_result_to_ruby_array(struct CompletionResult result, VALUE graph_obj) { + if (result.error != NULL) { + VALUE msg = rb_utf8_str_new_cstr(result.error); + free_c_string(result.error); + rb_raise(rb_eArgError, "%s", StringValueCStr(msg)); + } + + CompletionCandidateArray *array = result.candidates; if (array == NULL) { return rb_ary_new(); } @@ -501,7 +508,7 @@ static VALUE completion_candidates_to_ruby_array(CompletionCandidateArray *array return rb_ary_new(); } - VALUE result = rb_ary_new_capa((long)array->len); + VALUE ruby_array = rb_ary_new_capa((long)array->len); for (size_t i = 0; i < array->len; i++) { CCompletionCandidate item = array->items[i]; @@ -532,11 +539,11 @@ static VALUE completion_candidates_to_ruby_array(CompletionCandidateArray *array rb_raise(rb_eRuntimeError, "Unknown CCompletionCandidateKind: %d", item.kind); } - rb_ary_push(result, obj); + rb_ary_push(ruby_array, obj); } rdx_completion_candidates_free(array); - return result; + return ruby_array; } // Graph#complete_expression: (Array[String] nesting) -> Array[Declaration | Keyword] @@ -551,11 +558,11 @@ static VALUE rdxr_graph_complete_expression(VALUE self, VALUE nesting) { size_t nesting_count = RARRAY_LEN(nesting); char **converted_nesting = rdxi_str_array_to_char(nesting, nesting_count); - CompletionCandidateArray *results = + struct CompletionResult result = rdx_graph_complete_expression(graph, (const char *const *)converted_nesting, nesting_count); rdxi_free_str_array(converted_nesting, nesting_count); - return completion_candidates_to_ruby_array(results, self); + return completion_result_to_ruby_array(result, self); } // Graph#complete_namespace_access: (String name) -> Array[Declaration] @@ -566,8 +573,8 @@ static VALUE rdxr_graph_complete_namespace_access(VALUE self, VALUE name) { void *graph; TypedData_Get_Struct(self, void *, &graph_type, graph); - CompletionCandidateArray *results = rdx_graph_complete_namespace_access(graph, StringValueCStr(name)); - return completion_candidates_to_ruby_array(results, self); + struct CompletionResult result = rdx_graph_complete_namespace_access(graph, StringValueCStr(name)); + return completion_result_to_ruby_array(result, self); } // Graph#complete_method_call: (String name) -> Array[Declaration] @@ -578,8 +585,8 @@ static VALUE rdxr_graph_complete_method_call(VALUE self, VALUE name) { void *graph; TypedData_Get_Struct(self, void *, &graph_type, graph); - CompletionCandidateArray *results = rdx_graph_complete_method_call(graph, StringValueCStr(name)); - return completion_candidates_to_ruby_array(results, self); + struct CompletionResult result = rdx_graph_complete_method_call(graph, StringValueCStr(name)); + return completion_result_to_ruby_array(result, self); } // Graph#complete_method_argument: (String name, Array[String] nesting) -> Array[Declaration | Keyword | KeywordParameter] @@ -594,11 +601,11 @@ static VALUE rdxr_graph_complete_method_argument(VALUE self, VALUE name, VALUE n size_t nesting_count = RARRAY_LEN(nesting); char **converted_nesting = rdxi_str_array_to_char(nesting, nesting_count); - CompletionCandidateArray *results = rdx_graph_complete_method_argument( + struct CompletionResult result = rdx_graph_complete_method_argument( graph, StringValueCStr(name), (const char *const *)converted_nesting, nesting_count); rdxi_free_str_array(converted_nesting, nesting_count); - return completion_candidates_to_ruby_array(results, self); + return completion_result_to_ruby_array(result, self); } void rdxi_initialize_graph(VALUE moduleRubydex) { diff --git a/rbi/rubydex.rbi b/rbi/rubydex.rbi index ff448995..e1bc2b17 100644 --- a/rbi/rubydex.rbi +++ b/rbi/rubydex.rbi @@ -248,17 +248,17 @@ class Rubydex::Graph # # The nesting array represents the lexical scope stack, where the last element is the self type. An empty array # defaults to `Object` as the self type (top-level context). - sig { params(nesting: T::Array[String]).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + sig { params(nesting: T::Array[String]).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword)]) } def complete_expression(nesting); end # Returns completion candidates after a namespace access operator (e.g., `Foo::`). This includes all constants and # singleton methods for the namespace and its ancestors. - sig { params(name: String).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + sig { params(name: String).returns(T::Array[Rubydex::Declaration]) } def complete_namespace_access(name); end # Returns completion candidates after a method call operator (e.g., `foo.`). This includes all methods that exist on # the type of the receiver and its ancestors. - sig { params(name: String).returns(T::Array[T.any(Rubydex::Declaration, Rubydex::Keyword, Rubydex::KeywordParameter)]) } + sig { params(name: String).returns(T::Array[Rubydex::Method]) } def complete_method_call(name); end # Returns completion candidates inside a method call's argument list (e.g., `foo.bar(|)`). This includes everything diff --git a/rust/rubydex-sys/src/graph_api.rs b/rust/rubydex-sys/src/graph_api.rs index 2ef9c689..4705e127 100644 --- a/rust/rubydex-sys/src/graph_api.rs +++ b/rust/rubydex-sys/src/graph_api.rs @@ -715,48 +715,91 @@ unsafe fn completion_nesting_name_id( Ok(name_api::nesting_stack_to_name_id(graph, &self_name, nesting)) } -/// Runs completion for the given receiver and returns a structured array of candidates +/// The result of a completion operation, carrying either a candidate array or an error message. +#[repr(C)] +pub struct CompletionResult { + /// Non-null on success; null on error. + pub candidates: *mut CompletionCandidateArray, + /// Non-null on error; null on success. Caller must free with `free_c_string`. + pub error: *const c_char, +} + +impl CompletionResult { + fn success(candidates: *mut CompletionCandidateArray) -> Self { + Self { + candidates, + error: ptr::null(), + } + } + + fn error(message: &str) -> Self { + Self { + candidates: ptr::null_mut(), + error: CString::new(message) + .map(|s| s.into_raw().cast_const()) + .unwrap_or(ptr::null()), + } + } +} + +/// Runs completion for the given receiver and returns a structured result with candidates or an error message fn run_and_finalize_completion( graph: &mut Graph, receiver: CompletionReceiver, names_to_untrack: Vec, -) -> *mut CompletionCandidateArray { - let Ok(candidates) = query::completion_candidates(graph, CompletionContext::new(receiver)) else { - for name_id in names_to_untrack { - graph.untrack_name(name_id); +) -> CompletionResult { + let candidates = match query::completion_candidates(graph, CompletionContext::new(receiver)) { + Ok(candidates) => candidates, + Err(e) => { + for name_id in names_to_untrack { + graph.untrack_name(name_id); + } + return CompletionResult::error(&e.to_string()); } - return ptr::null_mut(); }; let entries: Vec = candidates .into_iter() - .filter_map(|candidate| { - Some(match candidate { - CompletionCandidate::Declaration(id) => { - let decl = graph.declarations().get(&id)?; - CCompletionCandidate { - kind: CCompletionCandidateKind::Declaration, - declaration: Box::into_raw(Box::new(CDeclaration::from_declaration(id, decl))), - name: ptr::null(), - documentation: ptr::null(), - } + .map(|candidate| match candidate { + CompletionCandidate::Declaration(id) => { + let decl = graph + .declarations() + .get(&id) + .expect("completion candidate declaration must exist in graph"); + CCompletionCandidate { + kind: CCompletionCandidateKind::Declaration, + declaration: Box::into_raw(Box::new(CDeclaration::from_declaration(id, decl))), + name: ptr::null(), + documentation: ptr::null(), } - CompletionCandidate::Keyword(kw) => CCompletionCandidate { - kind: CCompletionCandidateKind::Keyword, + } + CompletionCandidate::Keyword(kw) => CCompletionCandidate { + kind: CCompletionCandidateKind::Keyword, + declaration: ptr::null(), + name: CString::new(kw.name()) + .expect("keyword name must not contain NUL") + .into_raw() + .cast_const(), + documentation: CString::new(kw.documentation()) + .expect("keyword documentation must not contain NUL") + .into_raw() + .cast_const(), + }, + CompletionCandidate::KeywordArgument(str_id) => { + let name_str = graph + .strings() + .get(&str_id) + .expect("keyword argument string must exist in graph"); + CCompletionCandidate { + kind: CCompletionCandidateKind::KeywordParameter, declaration: ptr::null(), - name: CString::new(kw.name()).ok()?.into_raw().cast_const(), - documentation: CString::new(kw.documentation()).ok()?.into_raw().cast_const(), - }, - CompletionCandidate::KeywordArgument(str_id) => { - let name_str = graph.strings().get(&str_id)?; - CCompletionCandidate { - kind: CCompletionCandidateKind::KeywordParameter, - declaration: ptr::null(), - name: CString::new(name_str.as_str()).ok()?.into_raw().cast_const(), - documentation: ptr::null(), - } + name: CString::new(name_str.as_str()) + .expect("keyword argument name must not contain NUL") + .into_raw() + .cast_const(), + documentation: ptr::null(), } - }) + } }) .collect(); @@ -764,11 +807,12 @@ fn run_and_finalize_completion( graph.untrack_name(name_id); } - CompletionCandidateArray::from_vec(entries) + CompletionResult::success(CompletionCandidateArray::from_vec(entries)) } /// Returns expression completion candidates. -/// The caller must free the result with `rdx_completion_candidates_free`. +/// The caller must free candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. /// /// # Safety /// @@ -779,11 +823,11 @@ pub unsafe extern "C" fn rdx_graph_complete_expression( pointer: GraphPointer, nesting: *const *const c_char, nesting_count: usize, -) -> *mut CompletionCandidateArray { +) -> CompletionResult { with_mut_graph(pointer, |graph| { let Ok((name_id, names_to_untrack)) = (unsafe { completion_nesting_name_id(graph, nesting, nesting_count) }) else { - return ptr::null_mut(); + return CompletionResult::success(ptr::null_mut()); }; run_and_finalize_completion(graph, CompletionReceiver::Expression(name_id), names_to_untrack) @@ -791,7 +835,8 @@ pub unsafe extern "C" fn rdx_graph_complete_expression( } /// Returns namespace access completion candidates. -/// The caller must free the result with `rdx_completion_candidates_free`. +/// The caller must free candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. /// /// # Safety /// @@ -801,9 +846,9 @@ pub unsafe extern "C" fn rdx_graph_complete_expression( pub unsafe extern "C" fn rdx_graph_complete_namespace_access( pointer: GraphPointer, name: *const c_char, -) -> *mut CompletionCandidateArray { +) -> CompletionResult { let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { - return ptr::null_mut(); + return CompletionResult::success(ptr::null_mut()); }; with_mut_graph(pointer, |graph| { @@ -816,7 +861,8 @@ pub unsafe extern "C" fn rdx_graph_complete_namespace_access( } /// Returns method call completion candidates. -/// The caller must free the result with `rdx_completion_candidates_free`. +/// The caller must free candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. /// /// # Safety /// @@ -826,9 +872,9 @@ pub unsafe extern "C" fn rdx_graph_complete_namespace_access( pub unsafe extern "C" fn rdx_graph_complete_method_call( pointer: GraphPointer, name: *const c_char, -) -> *mut CompletionCandidateArray { +) -> CompletionResult { let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { - return ptr::null_mut(); + return CompletionResult::success(ptr::null_mut()); }; with_mut_graph(pointer, |graph| { @@ -841,7 +887,8 @@ pub unsafe extern "C" fn rdx_graph_complete_method_call( } /// Returns method argument completion candidates. -/// The caller must free the result with `rdx_completion_candidates_free`. +/// The caller must free candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. /// /// # Safety /// @@ -854,16 +901,16 @@ pub unsafe extern "C" fn rdx_graph_complete_method_argument( name: *const c_char, nesting: *const *const c_char, nesting_count: usize, -) -> *mut CompletionCandidateArray { +) -> CompletionResult { let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { - return ptr::null_mut(); + return CompletionResult::success(ptr::null_mut()); }; with_mut_graph(pointer, |graph| { let Ok((self_name_id, names_to_untrack)) = (unsafe { completion_nesting_name_id(graph, nesting, nesting_count) }) else { - return ptr::null_mut(); + return CompletionResult::success(ptr::null_mut()); }; run_and_finalize_completion( diff --git a/rust/rubydex/src/query.rs b/rust/rubydex/src/query.rs index b14645b0..7c5c0fe2 100644 --- a/rust/rubydex/src/query.rs +++ b/rust/rubydex/src/query.rs @@ -261,18 +261,25 @@ pub fn completion_candidates<'a>( } /// Resolves a declaration ID to a namespace, following constant aliases if necessary. -fn resolve_to_namespace(graph: &Graph, decl_id: DeclarationId) -> Result> { - if let Some(Declaration::Namespace(_)) = graph.declarations().get(&decl_id) { - return Ok(decl_id); - } - - if let Some(target_id) = graph.resolve_alias(&decl_id) - && let Some(Declaration::Namespace(_)) = graph.declarations().get(&target_id) - { - return Ok(target_id); +/// +/// Returns: +/// - `Ok(Some(id))` if the declaration is a namespace (directly or via alias) +/// - `Ok(None)` if the declaration does not exist in the graph +/// - `Err(...)` if the declaration exists but is not a namespace or alias to a namespace +fn resolve_to_namespace(graph: &Graph, decl_id: DeclarationId) -> Result, Box> { + match graph.declarations().get(&decl_id) { + Some(Declaration::Namespace(_)) => Ok(Some(decl_id)), + None => Ok(None), + Some(_) => { + if let Some(target_id) = graph.resolve_alias(&decl_id) + && let Some(Declaration::Namespace(_)) = graph.declarations().get(&target_id) + { + Ok(Some(target_id)) + } else { + Err(format!("Expected declaration {decl_id:?} to be a namespace or alias to a namespace").into()) + } + } } - - Err(format!("Expected declaration {decl_id:?} to be a namespace or alias to a namespace").into()) } /// Collect completion for a namespace access (e.g.: `Foo::`) @@ -281,7 +288,9 @@ fn namespace_access_completion<'a>( namespace_decl_id: DeclarationId, mut context: CompletionContext<'a>, ) -> Result, Box> { - let resolved_id = resolve_to_namespace(graph, namespace_decl_id)?; + let Some(resolved_id) = resolve_to_namespace(graph, namespace_decl_id)? else { + return Ok(Vec::new()); + }; let namespace = graph.declarations().get(&resolved_id).unwrap().as_namespace().unwrap(); let mut candidates = Vec::new(); @@ -328,7 +337,9 @@ fn method_call_completion<'a>( receiver_decl_id: DeclarationId, mut context: CompletionContext<'a>, ) -> Result, Box> { - let resolved_id = resolve_to_namespace(graph, receiver_decl_id)?; + let Some(resolved_id) = resolve_to_namespace(graph, receiver_decl_id)? else { + return Ok(Vec::new()); + }; let namespace = graph.declarations().get(&resolved_id).unwrap().as_namespace().unwrap(); let mut candidates = Vec::new(); diff --git a/test/graph_test.rb b/test/graph_test.rb index 1ec0deaa..a8efffc9 100644 --- a/test/graph_test.rb +++ b/test/graph_test.rb @@ -637,6 +637,37 @@ def test_complete_expression refute_empty(if_keyword.documentation) end + def test_complete_expression_with_empty_nesting + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", "class Object; end\nclass Foo; end", "ruby") + graph.resolve + + candidates = graph.complete_expression([]) + + # Top-level constants should be reachable (Object context) + constants = candidates.select { |c| c.is_a?(Rubydex::Declaration) } + assert(constants.any? { |c| c.name == "Foo" }) + + # Keywords should still be present + keywords = candidates.select { |c| c.is_a?(Rubydex::Keyword) } + assert(keywords.any? { |c| c.name == "if" }) + end + + def test_complete_expression_for_non_namespace_nesting + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", <<~RUBY, "ruby") + class Foo + def bar + end + end + RUBY + graph.resolve + + assert_raises(ArgumentError) do + graph.complete_expression(["Foo#bar()"]) + end + end + def test_complete_namespace_access graph = Rubydex::Graph.new graph.index_source("file:///foo.rb", <<~RUBY, "ruby") @@ -659,6 +690,21 @@ def bar; end assert(candidates.any? { |c| c.is_a?(Rubydex::Method) && c.name == "Foo::#bar()" }) end + def test_complete_namespace_access_for_non_namespace + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", <<~RUBY, "ruby") + class Foo + def bar + end + end + RUBY + graph.resolve + + assert_raises(ArgumentError) do + graph.complete_namespace_access("Foo#bar()") + end + end + def test_complete_method_call graph = Rubydex::Graph.new graph.index_source("file:///foo.rb", "class Foo\n def bar; end\n def baz; end\nend", "ruby") @@ -674,6 +720,21 @@ def test_complete_method_call assert_includes(method_names, "Foo#baz()") end + def test_complete_method_call_for_non_namespace + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", <<~RUBY, "ruby") + class Foo + def bar + end + end + RUBY + graph.resolve + + assert_raises(ArgumentError) do + graph.complete_method_call("Foo#bar()") + end + end + def test_complete_method_argument graph = Rubydex::Graph.new graph.index_source("file:///foo.rb", "class Foo\n def bar(name:); end\nend", "ruby") @@ -691,7 +752,22 @@ def test_complete_method_argument # KeywordParameter candidates keyword_params = candidates.select { |c| c.is_a?(Rubydex::KeywordParameter) } - assert(keyword_params.any? { |c| c.name == "name:" }) + assert(keyword_params.any? { |c| c.name == "name" }) + end + + def test_complete_method_argument_for_non_namespace_nesting + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", <<~RUBY, "ruby") + class Foo + def bar(name:) + end + end + RUBY + graph.resolve + + assert_raises(ArgumentError) do + graph.complete_method_argument("Foo#bar()", ["Foo#bar()"]) + end end def test_complete_expression_raises_with_wrong_types @@ -725,6 +801,25 @@ def test_completion_returns_empty_for_non_existent_declarations assert_equal([], graph.complete_method_call("DoesNotExist")) end + def test_complete_expression_for_non_existent_nesting + graph = Rubydex::Graph.new + graph.resolve + + assert_raises(ArgumentError) do + graph.complete_expression(["NonExistent"]) + end + end + + def test_complete_expression_on_unresolved_graph + graph = Rubydex::Graph.new + graph.index_source("file:///foo.rb", "class Foo; end", "ruby") + + # Nesting with a name that exists but hasn't been resolved + assert_raises(ArgumentError) do + graph.complete_expression(["Foo"]) + end + end + private def assert_diagnostics(expected, actual)