From c05f9d9b0ebd3aa64e2f8a589f01afc3f3e7ec6c Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Tue, 2 Jul 2024 17:53:34 -0400 Subject: [PATCH] Add local completion --- lib/ruby_lsp/document.rb | 38 +++++------ lib/ruby_lsp/listeners/completion.rb | 24 +++++++ lib/ruby_lsp/node_context.rb | 70 +++++++++++++++++++-- lib/ruby_lsp/requests/completion_resolve.rb | 2 + test/requests/completion_test.rb | 55 ++++++++++++++++ 5 files changed, 160 insertions(+), 29 deletions(-) diff --git a/lib/ruby_lsp/document.rb b/lib/ruby_lsp/document.rb index 09dcde31e..3753f6b3e 100644 --- a/lib/ruby_lsp/document.rb +++ b/lib/ruby_lsp/document.rb @@ -117,8 +117,18 @@ def locate(node, char_position, node_types: []) parent = T.let(nil, T.nilable(Prism::Node)) nesting_nodes = T.let( [], - T::Array[T.any(Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode)], + T::Array[T.any( + Prism::ClassNode, + Prism::ModuleNode, + Prism::SingletonClassNode, + Prism::DefNode, + Prism::BlockNode, + Prism::LambdaNode, + Prism::ProgramNode, + )], ) + + nesting_nodes << node if node.is_a?(Prism::ProgramNode) call_node = T.let(nil, T.nilable(Prism::CallNode)) until queue.empty? @@ -148,11 +158,8 @@ def locate(node, char_position, node_types: []) # Keep track of the nesting where we found the target. This is used to determine the fully qualified name of the # target when it is a constant case candidate - when Prism::ClassNode, Prism::ModuleNode - nesting_nodes << candidate - when Prism::SingletonClassNode - nesting_nodes << candidate - when Prism::DefNode + when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode, + Prism::LambdaNode nesting_nodes << candidate end @@ -193,24 +200,7 @@ def locate(node, char_position, node_types: []) end end - nesting = [] - surrounding_method = T.let(nil, T.nilable(String)) - - nesting_nodes.each do |node| - case node - when Prism::ClassNode, Prism::ModuleNode - nesting << node.constant_path.slice - when Prism::SingletonClassNode - nesting << "" - when Prism::DefNode - surrounding_method = node.name.to_s - next unless node.receiver.is_a?(Prism::SelfNode) - - nesting << "" - end - end - - NodeContext.new(closest, parent, nesting, call_node, surrounding_method) + NodeContext.new(closest, parent, nesting_nodes, call_node) end sig { returns(T::Boolean) } diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb index 70b0b6500..72e7de809 100644 --- a/lib/ruby_lsp/listeners/completion.rb +++ b/lib/ruby_lsp/listeners/completion.rb @@ -277,6 +277,8 @@ def complete_require_relative(node) sig { params(node: Prism::CallNode, name: String).void } def complete_methods(node, name) + add_local_completions(node, name) + type = @type_inferrer.infer_receiver_type(@node_context) return unless type @@ -317,6 +319,28 @@ def complete_methods(node, name) # We have not indexed this namespace, so we can't provide any completions end + sig { params(node: Prism::CallNode, name: String).void } + def add_local_completions(node, name) + return if @global_state.has_type_checker + + range = range_from_location(T.must(node.message_loc)) + + @node_context.locals_for_scope.each do |local| + local_name = local.to_s + next unless local_name.start_with?(name) + + @response_builder << Interface::CompletionItem.new( + label: local_name, + filter_text: local_name, + text_edit: Interface::TextEdit.new(range: range, new_text: local_name), + kind: Constant::CompletionItemKind::VARIABLE, + data: { + skip_resolve: true, + }, + ) + end + end + sig { params(label: String, node: Prism::StringNode).returns(Interface::CompletionItem) } def build_completion(label, node) # We should use the content location as we only replace the content and not the delimiters of the string diff --git a/lib/ruby_lsp/node_context.rb b/lib/ruby_lsp/node_context.rb index 6dfc57de7..8f5a8f235 100644 --- a/lib/ruby_lsp/node_context.rb +++ b/lib/ruby_lsp/node_context.rb @@ -23,22 +23,82 @@ class NodeContext params( node: T.nilable(Prism::Node), parent: T.nilable(Prism::Node), - nesting: T::Array[String], + nesting_nodes: T::Array[T.any( + Prism::ClassNode, + Prism::ModuleNode, + Prism::SingletonClassNode, + Prism::DefNode, + Prism::BlockNode, + Prism::LambdaNode, + Prism::ProgramNode, + )], call_node: T.nilable(Prism::CallNode), - surrounding_method: T.nilable(String), ).void end - def initialize(node, parent, nesting, call_node, surrounding_method) + def initialize(node, parent, nesting_nodes, call_node) @node = node @parent = parent - @nesting = nesting + @nesting_nodes = nesting_nodes @call_node = call_node - @surrounding_method = surrounding_method + + nesting, surrounding_method = handle_nesting_nodes(nesting_nodes) + @nesting = T.let(nesting, T::Array[String]) + @surrounding_method = T.let(surrounding_method, T.nilable(String)) end sig { returns(String) } def fully_qualified_name @fully_qualified_name ||= T.let(@nesting.join("::"), T.nilable(String)) end + + sig { returns(T::Array[Symbol]) } + def locals_for_scope + locals = [] + + @nesting_nodes.each do |node| + if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode) || + node.is_a?(Prism::DefNode) + locals.clear + end + + locals.concat(node.locals) + end + + locals + end + + private + + sig do + params(nodes: T::Array[T.any( + Prism::ClassNode, + Prism::ModuleNode, + Prism::SingletonClassNode, + Prism::DefNode, + Prism::BlockNode, + Prism::LambdaNode, + Prism::ProgramNode, + )]).returns([T::Array[String], T.nilable(String)]) + end + def handle_nesting_nodes(nodes) + nesting = [] + surrounding_method = T.let(nil, T.nilable(String)) + + @nesting_nodes.each do |node| + case node + when Prism::ClassNode, Prism::ModuleNode + nesting << node.constant_path.slice + when Prism::SingletonClassNode + nesting << "" + when Prism::DefNode + surrounding_method = node.name.to_s + next unless node.receiver.is_a?(Prism::SelfNode) + + nesting << "" + end + end + + [nesting, surrounding_method] + end end end diff --git a/lib/ruby_lsp/requests/completion_resolve.rb b/lib/ruby_lsp/requests/completion_resolve.rb index 668bf6e8f..21876001d 100644 --- a/lib/ruby_lsp/requests/completion_resolve.rb +++ b/lib/ruby_lsp/requests/completion_resolve.rb @@ -38,6 +38,8 @@ def initialize(global_state, item) sig { override.returns(T::Hash[Symbol, T.untyped]) } def perform + return @item if @item.dig(:data, :skip_resolve) + # Based on the spec https://microsoft.github.io/language-server-protocol/specification#textDocument_completion, # a completion resolve request must always return the original completion item without modifying ANY fields # other than detail and documentation (NOT labelDetails). If we modify anything, the completion behaviour might diff --git a/test/requests/completion_test.rb b/test/requests/completion_test.rb index 0e4c8b6c7..c1e3c35ce 100644 --- a/test/requests/completion_test.rb +++ b/test/requests/completion_test.rb @@ -1076,6 +1076,61 @@ def do_something end end + def test_completion_for_locals + source = +<<~RUBY + class Child + abc0 = 42 + + def do_something(abc1, abc2, abc3) + a + + [].each do |abc4, abc5| + a + end + end + + a + end + + abc = 12 + a + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 4, character: 5 }, + }) + + result = server.pop_response.response + assert_equal(["abc1", "abc2", "abc3"], result.map(&:label)) + + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 7, character: 7 }, + }) + + result = server.pop_response.response + assert_equal(["abc1", "abc2", "abc3", "abc4", "abc5"], result.map(&:label)) + + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 11, character: 3 }, + }) + + result = server.pop_response.response + assert_equal(["abc0"], result.map(&:label)) + + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 15, character: 1 }, + }) + + result = server.pop_response.response + assert_equal(["abc"], result.map(&:label)) + end + end + private def with_file_structure(server, &block)