From 3d193e45067cebd9a8562cba40eb0b3ea1a11ddd Mon Sep 17 00:00:00 2001 From: Ryo Nakamura Date: Sun, 11 Sep 2022 08:01:08 +0900 Subject: [PATCH] Add completion support --- README.md | 4 + lib/rucoa/configuration.rb | 10 + lib/rucoa/handlers.rb | 1 + lib/rucoa/handlers/initialize_handler.rb | 6 + .../text_document_completion_handler.rb | 216 ++++++++++++++++++ lib/rucoa/position.rb | 11 + lib/rucoa/server.rb | 1 + lib/rucoa/source.rb | 5 + .../text_document_completion_handler_spec.rb | 204 +++++++++++++++++ 9 files changed, 458 insertions(+) create mode 100644 lib/rucoa/handlers/text_document_completion_handler.rb create mode 100644 spec/rucoa/handlers/text_document_completion_handler_spec.rb diff --git a/README.md b/README.md index a090fa3..d4b8fc4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ This extension supports the folowiing types of symbols: ![demo](images/document-symbol.gif) +### Completion (experimental) + +Provides completion items for constant names and method names. + ### Signature help (experimental) Shows method signature help when you start to type method arguments like `"100".to_i(`. diff --git a/lib/rucoa/configuration.rb b/lib/rucoa/configuration.rb index 5c3738d..521e440 100644 --- a/lib/rucoa/configuration.rb +++ b/lib/rucoa/configuration.rb @@ -11,6 +11,11 @@ def disable_code_action disable_feature('codeAction') end + # @return [void] + def disable_completion + disable_feature('completion') + end + # @return [void] def disable_diagnostics disable_feature('diagnostics') @@ -67,6 +72,11 @@ def enables_code_action? enables_feature?('codeAction') end + # @return [Boolean] + def enables_completion? + enables_feature?('completion') + end + # @return [Boolean] def enables_diagnostics? enables_feature?('diagnostics') diff --git a/lib/rucoa/handlers.rb b/lib/rucoa/handlers.rb index 1b94e8c..1ea0354 100644 --- a/lib/rucoa/handlers.rb +++ b/lib/rucoa/handlers.rb @@ -8,6 +8,7 @@ module Handlers autoload :InitializedHandler, 'rucoa/handlers/initialized_handler' autoload :ShutdownHandler, 'rucoa/handlers/shutdown_handler' autoload :TextDocumentCodeActionHandler, 'rucoa/handlers/text_document_code_action_handler' + autoload :TextDocumentCompletionHandler, 'rucoa/handlers/text_document_completion_handler' autoload :TextDocumentDidChangeHandler, 'rucoa/handlers/text_document_did_change_handler' autoload :TextDocumentDidOpenHandler, 'rucoa/handlers/text_document_did_open_handler' autoload :TextDocumentDocumentSymbolHandler, 'rucoa/handlers/text_document_document_symbol_handler' diff --git a/lib/rucoa/handlers/initialize_handler.rb b/lib/rucoa/handlers/initialize_handler.rb index cdc9d72..396e20d 100644 --- a/lib/rucoa/handlers/initialize_handler.rb +++ b/lib/rucoa/handlers/initialize_handler.rb @@ -7,6 +7,12 @@ def call respond( capabilities: { codeActionProvider: true, + completionProvider: { + resolveProvider: true, + triggerCharacters: %w[ + . + ] + }, documentFormattingProvider: true, documentRangeFormattingProvider: true, documentSymbolProvider: true, diff --git a/lib/rucoa/handlers/text_document_completion_handler.rb b/lib/rucoa/handlers/text_document_completion_handler.rb new file mode 100644 index 0000000..79604fb --- /dev/null +++ b/lib/rucoa/handlers/text_document_completion_handler.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +module Rucoa + module Handlers + class TextDocumentCompletionHandler < Base + COMPLETION_ITEM_KIND_FOR_TEXT = 1 + COMPLETION_ITEM_KIND_FOR_METHOD = 2 + COMPLETION_ITEM_KIND_FOR_FUNCTION = 3 + COMPLETION_ITEM_KIND_FOR_CONSTRUCTOR = 4 + COMPLETION_ITEM_KIND_FOR_FIELD = 5 + COMPLETION_ITEM_KIND_FOR_VARIABLE = 6 + COMPLETION_ITEM_KIND_FOR_CLASS = 7 + COMPLETION_ITEM_KIND_FOR_INTERFACE = 8 + COMPLETION_ITEM_KIND_FOR_MODULE = 9 + COMPLETION_ITEM_KIND_FOR_PROPERTY = 10 + COMPLETION_ITEM_KIND_FOR_UNIT = 11 + COMPLETION_ITEM_KIND_FOR_VALUE = 12 + COMPLETION_ITEM_KIND_FOR_ENUM = 13 + COMPLETION_ITEM_KIND_FOR_KEYWORD = 14 + COMPLETION_ITEM_KIND_FOR_SNIPPET = 15 + COMPLETION_ITEM_KIND_FOR_COLOR = 16 + COMPLETION_ITEM_KIND_FOR_FILE = 17 + COMPLETION_ITEM_KIND_FOR_REFERENCE = 18 + COMPLETION_ITEM_KIND_FOR_FOLDER = 19 + COMPLETION_ITEM_KIND_FOR_ENUM_MEMBER = 20 + COMPLETION_ITEM_KIND_FOR_CONSTANT = 21 + COMPLETION_ITEM_KIND_FOR_STRUCT = 22 + COMPLETION_ITEM_KIND_FOR_EVENT = 23 + COMPLETION_ITEM_KIND_FOR_OPERATOR = 24 + COMPLETION_ITEM_KIND_FOR_TYPE_PARAMETER = 25 + + EXAMPLE_IDENTIFIER = 'a' + private_constant :EXAMPLE_IDENTIFIER + + def call + respond(completion_items) + end + + private + + # @return [Array, nil] + def completion_items + return unless responsible? + + case node + when Nodes::ConstNode + completion_items_for_constant + when Nodes::SendNode + if node.location.dot&.is?('::') + completion_items_for_constant + else + completion_items_for_method + end + else + [] + end + end + + # @return [Boolean] + def responsible? + configuration.enables_completion? && + !source.nil? + end + + # @return [Rucoa::Source, nil] + def source + @source ||= source_store.get(uri) + end + + # @return [Rucoa::Position] + def position + @position ||= Position.from_vscode_position( + request.dig('params', 'position') + ) + end + + # @return [String] + def uri + request.dig('params', 'textDocument', 'uri') + end + + # @return [Array] + def completion_items_for_method + completable_method_names.map do |method_name| + { + kind: COMPLETION_ITEM_KIND_FOR_METHOD, + label: method_name, + textEdit: { + newText: method_name, + range: range.to_vscode_range + } + } + end + end + + # @return [Array] + def completion_items_for_constant + completable_constant_names.map do |constant_name| + { + kind: COMPLETION_ITEM_KIND_FOR_CONSTANT, + label: constant_name, + textEdit: { + newText: constant_name, + range: range.to_vscode_range + } + } + end + end + + # @return [Array] + def completable_constant_names + referrable_constant_names.select do |constant_name| + constant_name.start_with?(completion_head) + end.sort + end + + # @return [String] e.g. "SE" to `File::SE|`, "ba" to `foo.ba|` + def completion_head + @completion_head ||= + if @repaired + '' + else + node.name + end + end + + def referrable_constant_names + definition_store.constant_definitions_under(constant_namespace).map(&:name).uniq + end + + # @return [String] e.g. "Foo::Bar" to `Foo::Bar.baz|`. + def constant_namespace + node.each_child_node(:const).map(&:name).reverse.join('::') + end + + # @return [Array] + def completable_method_names + callable_method_names.select do |method_name| + method_name.start_with?(completion_head) + end.sort + end + + # @return [Array] + def callable_method_names + callable_method_definitions.map(&:method_name).uniq + end + + # @return [Array] + def callable_method_definitions + receiver_types.flat_map do |type| + definition_store.method_definitions_of(type) + end + end + + # @return [Array] + def receiver_types + NodeInspector.new( + definition_store: definition_store, + node: node + ).method_receiver_types + end + + # @return [Rucoa::Node, nil] + def node + @node ||= + if source.syntax_error? + repair + repaired_node + else + normal_node + end + end + + # @return [Rucoa::Node, nil] + def normal_node + source.node_at(position) + end + + # @return [Rucoa::Node, nil] + def repaired_node + repaired_source.node_at(position) + end + + # @return [void] + def repair + @repaired = true + end + + # @return [String] + def repaired_content + source.content.dup.insert( + position.to_index_of(source.content), + EXAMPLE_IDENTIFIER + ) + end + + # @return [Rucoa::Source] + def repaired_source + Source.new( + content: repaired_content, + uri: source.uri + ) + end + + # @return [Rucoa::Range] + def range + @range ||= + if @repaired + position.to_range + else + Range.from_parser_range(node.location.expression) + end + end + end + end +end diff --git a/lib/rucoa/position.rb b/lib/rucoa/position.rb index 043963b..eae7064 100644 --- a/lib/rucoa/position.rb +++ b/lib/rucoa/position.rb @@ -44,6 +44,17 @@ def initialize(column:, line:) @line = line end + # @param text [String] + # @return [Integer] + def to_index_of(text) + text.each_line.take(@line - 1).sum(&:length) + @column + end + + # @return [Rucoa::Range] + def to_range + Range.new(self, self) + end + # @return [Hash] def to_vscode_position { diff --git a/lib/rucoa/server.rb b/lib/rucoa/server.rb index 630a2ef..2a6c583 100644 --- a/lib/rucoa/server.rb +++ b/lib/rucoa/server.rb @@ -12,6 +12,7 @@ class Server 'initialized' => Handlers::InitializedHandler, 'shutdown' => Handlers::ShutdownHandler, 'textDocument/codeAction' => Handlers::TextDocumentCodeActionHandler, + 'textDocument/completion' => Handlers::TextDocumentCompletionHandler, 'textDocument/didChange' => Handlers::TextDocumentDidChangeHandler, 'textDocument/didOpen' => Handlers::TextDocumentDidOpenHandler, 'textDocument/documentSymbol' => Handlers::TextDocumentDocumentSymbolHandler, diff --git a/lib/rucoa/source.rb b/lib/rucoa/source.rb index e47d65a..46a1074 100644 --- a/lib/rucoa/source.rb +++ b/lib/rucoa/source.rb @@ -78,6 +78,11 @@ def root_node nil end + # @return [Boolean] + def syntax_error? + root_node.nil? + end + private # @return [Array] diff --git a/spec/rucoa/handlers/text_document_completion_handler_spec.rb b/spec/rucoa/handlers/text_document_completion_handler_spec.rb new file mode 100644 index 0000000..95808b0 --- /dev/null +++ b/spec/rucoa/handlers/text_document_completion_handler_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'stringio' + +RSpec.describe Rucoa::Handlers::TextDocumentCompletionHandler do + describe '.call' do + subject do + described_class.call( + request: request, + server: server + ) + end + + before do + server.source_store.update(source) + end + + let(:request) do + { + 'id' => 1, + 'method' => 'textDocument/completion', + 'params' => { + 'position' => position.to_vscode_position, + 'textDocument' => { + 'uri' => uri + } + } + } + end + + let(:server) do + Rucoa::Server.new + end + + let(:uri) do + 'file:///path/to/file.rb' + end + + let(:content) do + <<~RUBY + '10'. + RUBY + end + + let(:position) do + Rucoa::Position.new( + column: 5, + line: 1 + ) + end + + let(:source) do + Rucoa::Source.new( + content: content, + uri: uri + ) + end + + context 'when completion is disabled' do + before do + server.configuration.disable_completion + end + + it 'responds nil result' do + subject + expect(server.responses).to match( + [ + hash_including( + 'id' => 1, + 'result' => nil + ) + ] + ) + end + end + + context 'with method head part' do + let(:content) do + <<~RUBY + '10'.to_sy + RUBY + end + + let(:position) do + Rucoa::Position.new( + column: 10, + line: 1 + ) + end + + it 'responds completion items' do + subject + expect(server.responses).to match( + [ + hash_including( + 'id' => 1, + 'result' => [ + hash_including( + 'label' => 'to_sym' + ) + ] + ) + ] + ) + end + end + + context 'with method dot' do + it 'responds completion items' do + subject + expect(server.responses).to match( + [ + hash_including( + 'id' => 1, + 'result' => array_including( + hash_including( + 'label' => 'to_i', + 'textEdit' => { + 'newText' => 'to_i', + 'range' => { + 'end' => { + 'character' => 5, + 'line' => 0 + }, + 'start' => { + 'character' => 5, + 'line' => 0 + } + } + } + ) + ) + ) + ] + ) + end + end + + context 'with constant head part' do + let(:content) do + <<~RUBY + File::SE + RUBY + end + + let(:position) do + Rucoa::Position.new( + column: 8, + line: 1 + ) + end + + it 'responds completion items' do + subject + expect(server.responses).to match( + [ + hash_including( + 'id' => 1, + 'result' => [ + hash_including( + 'label' => 'SEPARATOR' + ) + ] + ) + ] + ) + end + end + + context 'with constant ::' do + let(:content) do + <<~RUBY + File:: + RUBY + end + + let(:position) do + Rucoa::Position.new( + column: 6, + line: 1 + ) + end + + it 'responds completion items' do + subject + expect(server.responses).to match( + [ + hash_including( + 'id' => 1, + 'result' => array_including( + hash_including( + 'label' => 'PATH_SEPARATOR' + ), + hash_including( + 'label' => 'SEPARATOR' + ) + ) + ) + ] + ) + end + end + end +end