diff --git a/ext/rubydex/graph.c b/ext/rubydex/graph.c index a56876c6..d4929938 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,130 @@ static VALUE rdxr_graph_diagnostics(VALUE self) { return diagnostics; } +// 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(); + } + + if (array->len == 0) { + rdx_completion_candidates_free(array); + return rb_ary_new(); + } + + VALUE ruby_array = 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(ruby_array, obj); + } + + rdx_completion_candidates_free(array); + return ruby_array; +} + +// 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); + + 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_result_to_ruby_array(result, 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); + + 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] +// 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); + + 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] +// 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); + + 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_result_to_ruby_array(result, 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 +632,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/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..e1bc2b17 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 @@ -224,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)]) } + 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[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[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 + # 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..4705e127 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,305 @@ 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)) +} + +/// 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, +) -> 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()); + } + }; + + let entries: Vec = candidates + .into_iter() + .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, + 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(name_str.as_str()) + .expect("keyword argument name must not contain NUL") + .into_raw() + .cast_const(), + documentation: ptr::null(), + } + } + }) + .collect(); + + for name_id in names_to_untrack { + graph.untrack_name(name_id); + } + + CompletionResult::success(CompletionCandidateArray::from_vec(entries)) +} + +/// Returns expression completion candidates. +/// The caller must free candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. +/// +/// # 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, +) -> CompletionResult { + with_mut_graph(pointer, |graph| { + let Ok((name_id, names_to_untrack)) = (unsafe { completion_nesting_name_id(graph, nesting, nesting_count) }) + else { + return CompletionResult::success(ptr::null_mut()); + }; + + run_and_finalize_completion(graph, CompletionReceiver::Expression(name_id), names_to_untrack) + }) +} + +/// Returns namespace access completion candidates. +/// The caller must free candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. +/// +/// # 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, +) -> CompletionResult { + let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { + return CompletionResult::success(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 candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. +/// +/// # 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, +) -> CompletionResult { + let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { + return CompletionResult::success(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 candidates with `rdx_completion_candidates_free` +/// and the error string (if non-null) with `free_c_string`. +/// +/// # 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, +) -> CompletionResult { + let Ok(name_str) = (unsafe { utils::convert_char_ptr_to_string(name) }) else { + 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 CompletionResult::success(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/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 081bbeeb..a8efffc9 100644 --- a/test/graph_test.rb +++ b/test/graph_test.rb @@ -610,6 +610,216 @@ 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_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") + 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_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") + 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_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") + 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_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 + 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 + + 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)