From 9b74a2bd560de44427fd759e70cd937364a89fec Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Mon, 18 Dec 2023 15:42:12 +0000 Subject: [PATCH] Decouple Completion's request and listener logic --- lib/ruby_lsp/executor.rb | 53 +---- lib/ruby_lsp/listeners/completion.rb | 274 +++++++++++++++++++++++++ lib/ruby_lsp/requests/completion.rb | 294 ++++----------------------- 3 files changed, 320 insertions(+), 301 deletions(-) create mode 100644 lib/ruby_lsp/listeners/completion.rb diff --git a/lib/ruby_lsp/executor.rb b/lib/ruby_lsp/executor.rb index db20df516d..8fbba569af 100644 --- a/lib/ruby_lsp/executor.rb +++ b/lib/ruby_lsp/executor.rb @@ -168,7 +168,7 @@ def run(request) nil end when "textDocument/completion" - completion(uri, request.dig(:params, :position)) + Requests::Completion.new(@store.get(uri), @index, request.dig(:params, :position)).response when "textDocument/definition" Requests::Definition.new(@store.get(uri), uri, @index, request.dig(:params, :position)).response when "workspace/didChangeWatchedFiles" @@ -419,57 +419,6 @@ def semantic_tokens_range(uri, range) Requests::Support::SemanticTokenEncoder.new.encode(listener.response) end - sig do - params( - uri: URI::Generic, - position: Document::PositionShape, - ).returns(T.nilable(T::Array[Interface::CompletionItem])) - end - def completion(uri, position) - document = @store.get(uri) - - # Completion always receives the position immediately after the character that was just typed. Here we adjust it - # back by 1, so that we find the right node - char_position = document.create_scanner.find_char_position(position) - 1 - matched, parent, nesting = document.locate( - document.tree, - char_position, - node_types: [Prism::CallNode, Prism::ConstantReadNode, Prism::ConstantPathNode], - ) - return unless matched && parent - - target = case matched - when Prism::CallNode - message = matched.message - - if message == "require" - args = matched.arguments&.arguments - return if args.nil? || args.is_a?(Prism::ForwardingArgumentsNode) - - argument = args.first - return unless argument.is_a?(Prism::StringNode) - return unless (argument.location.start_offset..argument.location.end_offset).cover?(char_position) - - argument - else - matched - end - when Prism::ConstantReadNode, Prism::ConstantPathNode - if parent.is_a?(Prism::ConstantPathNode) && matched.is_a?(Prism::ConstantReadNode) - parent - else - matched - end - end - - return unless target - - dispatcher = Prism::Dispatcher.new - listener = Requests::Completion.new(@index, nesting, dispatcher) - dispatcher.dispatch_once(target) - listener.response - end - sig { params(id: String, title: String, percentage: Integer).void } def begin_progress(id, title, percentage: 0) return unless @store.supports_progress diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb new file mode 100644 index 0000000000..02ec00e487 --- /dev/null +++ b/lib/ruby_lsp/listeners/completion.rb @@ -0,0 +1,274 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class Completion < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + index: RubyIndexer::Index, + nesting: T::Array[String], + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(index, nesting, dispatcher) + super(dispatcher) + @_response = T.let([], ResponseType) + @index = index + @nesting = nesting + + dispatcher.register( + self, + :on_string_node_enter, + :on_constant_path_node_enter, + :on_constant_read_node_enter, + :on_call_node_enter, + ) + end + + sig { params(node: Prism::StringNode).void } + def on_string_node_enter(node) + @index.search_require_paths(node.content).map!(&:require_path).sort!.each do |path| + @_response << build_completion(T.must(path), node) + end + end + + # Handle completion on regular constant references (e.g. `Bar`) + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + return if DependencyDetector.instance.typechecker + + name = node.slice + candidates = @index.prefix_search(name, @nesting) + candidates.each do |entries| + complete_name = T.must(entries.first).name + @_response << build_entry_completion( + complete_name, + name, + node, + entries, + top_level?(complete_name), + ) + end + end + + # Handle completion on namespaced constant references (e.g. `Foo::Bar`) + sig { params(node: Prism::ConstantPathNode).void } + def on_constant_path_node_enter(node) + return if DependencyDetector.instance.typechecker + + name = node.slice + + top_level_reference = if name.start_with?("::") + name = name.delete_prefix("::") + true + else + false + end + + # If we're trying to provide completion for an aliased namespace, we need to first discover it's real name in + # order to find which possible constants match the desired search + *namespace, incomplete_name = name.split("::") + aliased_namespace = T.must(namespace).join("::") + namespace_entries = @index.resolve(aliased_namespace, @nesting) + return unless namespace_entries + + real_namespace = @index.follow_aliased_namespace(T.must(namespace_entries.first).name) + + candidates = @index.prefix_search("#{real_namespace}::#{incomplete_name}", top_level_reference ? [] : @nesting) + candidates.each do |entries| + # The only time we may have a private constant reference from outside of the namespace is if we're dealing + # with ConstantPath and the entry name doesn't start with the current nesting + first_entry = T.must(entries.first) + next if first_entry.visibility == :private && !first_entry.name.start_with?("#{@nesting}::") + + constant_name = T.must(first_entry.name.split("::").last) + + full_name = aliased_namespace.empty? ? constant_name : "#{aliased_namespace}::#{constant_name}" + + @_response << build_entry_completion( + full_name, + name, + node, + entries, + top_level_reference || top_level?(T.must(entries.first).name), + ) + end + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return if DependencyDetector.instance.typechecker + return unless self_receiver?(node) + + name = node.message + return unless name + + receiver_entries = @index[@nesting.join("::")] + return unless receiver_entries + + receiver = T.must(receiver_entries.first) + + @index.prefix_search(name).each do |entries| + entry = entries.find { |e| e.is_a?(RubyIndexer::Entry::Member) && e.owner&.name == receiver.name } + next unless entry + + @_response << build_method_completion(T.cast(entry, RubyIndexer::Entry::Member), node) + end + end + + private + + sig do + params( + entry: RubyIndexer::Entry::Member, + node: Prism::CallNode, + ).returns(Interface::CompletionItem) + end + def build_method_completion(entry, node) + name = entry.name + parameters = entry.parameters + new_text = parameters.empty? ? name : "#{name}(#{parameters.map(&:name).join(", ")})" + + Interface::CompletionItem.new( + label: name, + filter_text: name, + text_edit: Interface::TextEdit.new(range: range_from_node(node), new_text: new_text), + kind: Constant::CompletionItemKind::METHOD, + label_details: Interface::CompletionItemLabelDetails.new( + description: entry.file_name, + ), + documentation: markdown_from_index_entries(name, entry), + ) + 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 + loc = node.content_loc + + Interface::CompletionItem.new( + label: label, + text_edit: Interface::TextEdit.new( + range: range_from_location(loc), + new_text: label, + ), + kind: Constant::CompletionItemKind::FILE, + ) + end + + sig do + params( + real_name: String, + incomplete_name: String, + node: Prism::Node, + entries: T::Array[RubyIndexer::Entry], + top_level: T::Boolean, + ).returns(Interface::CompletionItem) + end + def build_entry_completion(real_name, incomplete_name, node, entries, top_level) + first_entry = T.must(entries.first) + kind = case first_entry + when RubyIndexer::Entry::Class + Constant::CompletionItemKind::CLASS + when RubyIndexer::Entry::Module + Constant::CompletionItemKind::MODULE + when RubyIndexer::Entry::Constant + Constant::CompletionItemKind::CONSTANT + else + Constant::CompletionItemKind::REFERENCE + end + + insertion_text = real_name.dup + filter_text = real_name.dup + + # If we have two entries with the same name inside the current namespace and the user selects the top level + # option, we have to ensure it's prefixed with `::` or else we're completing the wrong constant. For example: + # If we have the index with ["Foo::Bar", "Bar"], and we're providing suggestions for `B` inside a `Foo` module, + # then selecting the `Foo::Bar` option needs to complete to `Bar` and selecting the top level `Bar` option needs + # to complete to `::Bar`. + if top_level + insertion_text.prepend("::") + filter_text.prepend("::") + end + + # If the user is searching for a constant inside the current namespace, then we prefer completing the short name + # of that constant. E.g.: + # + # module Foo + # class Bar + # end + # + # Foo::B # --> completion inserts `Bar` instead of `Foo::Bar` + # end + @nesting.each do |namespace| + prefix = "#{namespace}::" + shortened_name = insertion_text.delete_prefix(prefix) + + # If a different entry exists for the shortened name, then there's a conflict and we should not shorten it + conflict_name = "#{@nesting.join("::")}::#{shortened_name}" + break if real_name != conflict_name && @index[conflict_name] + + insertion_text = shortened_name + + # If the user is typing a fully qualified name `Foo::Bar::Baz`, then we should not use the short name (e.g.: + # `Baz`) as filtering. So we only shorten the filter text if the user is not including the namespaces in their + # typing + filter_text.delete_prefix!(prefix) unless incomplete_name.start_with?(prefix) + end + + # When using a top level constant reference (e.g.: `::Bar`), the editor includes the `::` as part of the filter. + # For these top level references, we need to include the `::` as part of the filter text or else it won't match + # the right entries in the index + Interface::CompletionItem.new( + label: real_name, + filter_text: filter_text, + text_edit: Interface::TextEdit.new( + range: range_from_node(node), + new_text: insertion_text, + ), + kind: kind, + label_details: Interface::CompletionItemLabelDetails.new( + description: entries.map(&:file_name).join(","), + ), + documentation: markdown_from_index_entries(real_name, entries), + ) + end + + # Check if there are any conflicting names for `entry_name`, which would require us to use a top level reference. + # For example: + # + # ```ruby + # class Bar; end + # + # module Foo + # class Bar; end + # + # # in this case, the completion for `Bar` conflicts with `Foo::Bar`, so we can't suggest `Bar` as the + # # completion, but instead need to suggest `::Bar` + # B + # end + # ``` + sig { params(entry_name: String).returns(T::Boolean) } + def top_level?(entry_name) + @nesting.length.downto(0).each do |i| + prefix = T.must(@nesting[0...i]).join("::") + full_name = prefix.empty? ? entry_name : "#{prefix}::#{entry_name}" + next if full_name == entry_name + + return true if @index[full_name] + end + + false + end + end + end +end diff --git a/lib/ruby_lsp/requests/completion.rb b/lib/ruby_lsp/requests/completion.rb index 5391ebc697..bc948af901 100644 --- a/lib/ruby_lsp/requests/completion.rb +++ b/lib/ruby_lsp/requests/completion.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require_relative "../listeners/completion" + module RubyLsp module Requests # ![Completion demo](../../completion.gif) @@ -22,271 +24,65 @@ module Requests # # RubyLsp::Requests:: # --> completion: suggests `Completion`, `Hover`, ... # ``` - class Completion < Listener + class Completion < ListenerBasedRequest extend T::Sig extend T::Generic ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( + document: Document, index: RubyIndexer::Index, - nesting: T::Array[String], - dispatcher: Prism::Dispatcher, + position: Document::PositionShape, ).void end - def initialize(index, nesting, dispatcher) - super(dispatcher) - @_response = T.let([], ResponseType) - @index = index - @nesting = nesting - - dispatcher.register( - self, - :on_string_node_enter, - :on_constant_path_node_enter, - :on_constant_read_node_enter, - :on_call_node_enter, - ) - end - - sig { params(node: Prism::StringNode).void } - def on_string_node_enter(node) - @index.search_require_paths(node.content).map!(&:require_path).sort!.each do |path| - @_response << build_completion(T.must(path), node) - end - end - - # Handle completion on regular constant references (e.g. `Bar`) - sig { params(node: Prism::ConstantReadNode).void } - def on_constant_read_node_enter(node) - return if DependencyDetector.instance.typechecker - - name = node.slice - candidates = @index.prefix_search(name, @nesting) - candidates.each do |entries| - complete_name = T.must(entries.first).name - @_response << build_entry_completion( - complete_name, - name, - node, - entries, - top_level?(complete_name), - ) - end - end - - # Handle completion on namespaced constant references (e.g. `Foo::Bar`) - sig { params(node: Prism::ConstantPathNode).void } - def on_constant_path_node_enter(node) - return if DependencyDetector.instance.typechecker - - name = node.slice - - top_level_reference = if name.start_with?("::") - name = name.delete_prefix("::") - true - else - false - end - - # If we're trying to provide completion for an aliased namespace, we need to first discover it's real name in - # order to find which possible constants match the desired search - *namespace, incomplete_name = name.split("::") - aliased_namespace = T.must(namespace).join("::") - namespace_entries = @index.resolve(aliased_namespace, @nesting) - return unless namespace_entries - - real_namespace = @index.follow_aliased_namespace(T.must(namespace_entries.first).name) - - candidates = @index.prefix_search("#{real_namespace}::#{incomplete_name}", top_level_reference ? [] : @nesting) - candidates.each do |entries| - # The only time we may have a private constant reference from outside of the namespace is if we're dealing - # with ConstantPath and the entry name doesn't start with the current nesting - first_entry = T.must(entries.first) - next if first_entry.visibility == :private && !first_entry.name.start_with?("#{@nesting}::") - - constant_name = T.must(first_entry.name.split("::").last) - - full_name = aliased_namespace.empty? ? constant_name : "#{aliased_namespace}::#{constant_name}" - - @_response << build_entry_completion( - full_name, - name, - node, - entries, - top_level_reference || top_level?(T.must(entries.first).name), - ) - end - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return if DependencyDetector.instance.typechecker - return unless self_receiver?(node) - - name = node.message - return unless name - - receiver_entries = @index[@nesting.join("::")] - return unless receiver_entries - - receiver = T.must(receiver_entries.first) - - @index.prefix_search(name).each do |entries| - entry = entries.find { |e| e.is_a?(RubyIndexer::Entry::Member) && e.owner&.name == receiver.name } - next unless entry - - @_response << build_method_completion(T.cast(entry, RubyIndexer::Entry::Member), node) - end - end - - private - - sig do - params( - entry: RubyIndexer::Entry::Member, - node: Prism::CallNode, - ).returns(Interface::CompletionItem) - end - def build_method_completion(entry, node) - name = entry.name - parameters = entry.parameters - new_text = parameters.empty? ? name : "#{name}(#{parameters.map(&:name).join(", ")})" - - Interface::CompletionItem.new( - label: name, - filter_text: name, - text_edit: Interface::TextEdit.new(range: range_from_node(node), new_text: new_text), - kind: Constant::CompletionItemKind::METHOD, - label_details: Interface::CompletionItemLabelDetails.new( - description: entry.file_name, - ), - documentation: markdown_from_index_entries(name, entry), + def initialize(document, index, position) + # Completion always receives the position immediately after the character that was just typed. Here we adjust it + # back by 1, so that we find the right node + char_position = document.create_scanner.find_char_position(position) - 1 + matched, parent, nesting = document.locate( + document.tree, + char_position, + node_types: [Prism::CallNode, Prism::ConstantReadNode, Prism::ConstantPathNode], ) - 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 - loc = node.content_loc - - Interface::CompletionItem.new( - label: label, - text_edit: Interface::TextEdit.new( - range: range_from_location(loc), - new_text: label, - ), - kind: Constant::CompletionItemKind::FILE, - ) - end - - sig do - params( - real_name: String, - incomplete_name: String, - node: Prism::Node, - entries: T::Array[RubyIndexer::Entry], - top_level: T::Boolean, - ).returns(Interface::CompletionItem) - end - def build_entry_completion(real_name, incomplete_name, node, entries, top_level) - first_entry = T.must(entries.first) - kind = case first_entry - when RubyIndexer::Entry::Class - Constant::CompletionItemKind::CLASS - when RubyIndexer::Entry::Module - Constant::CompletionItemKind::MODULE - when RubyIndexer::Entry::Constant - Constant::CompletionItemKind::CONSTANT - else - Constant::CompletionItemKind::REFERENCE - end - - insertion_text = real_name.dup - filter_text = real_name.dup - - # If we have two entries with the same name inside the current namespace and the user selects the top level - # option, we have to ensure it's prefixed with `::` or else we're completing the wrong constant. For example: - # If we have the index with ["Foo::Bar", "Bar"], and we're providing suggestions for `B` inside a `Foo` module, - # then selecting the `Foo::Bar` option needs to complete to `Bar` and selecting the top level `Bar` option needs - # to complete to `::Bar`. - if top_level - insertion_text.prepend("::") - filter_text.prepend("::") + return unless matched && parent + + target = case matched + when Prism::CallNode + message = matched.message + + if message == "require" + args = matched.arguments&.arguments + return if args.nil? || args.is_a?(Prism::ForwardingArgumentsNode) + + argument = args.first + return unless argument.is_a?(Prism::StringNode) + return unless (argument.location.start_offset..argument.location.end_offset).cover?(char_position) + + argument + else + matched + end + when Prism::ConstantReadNode, Prism::ConstantPathNode + if parent.is_a?(Prism::ConstantPathNode) && matched.is_a?(Prism::ConstantReadNode) + parent + else + matched + end end - # If the user is searching for a constant inside the current namespace, then we prefer completing the short name - # of that constant. E.g.: - # - # module Foo - # class Bar - # end - # - # Foo::B # --> completion inserts `Bar` instead of `Foo::Bar` - # end - @nesting.each do |namespace| - prefix = "#{namespace}::" - shortened_name = insertion_text.delete_prefix(prefix) - - # If a different entry exists for the shortened name, then there's a conflict and we should not shorten it - conflict_name = "#{@nesting.join("::")}::#{shortened_name}" - break if real_name != conflict_name && @index[conflict_name] - - insertion_text = shortened_name + return unless target - # If the user is typing a fully qualified name `Foo::Bar::Baz`, then we should not use the short name (e.g.: - # `Baz`) as filtering. So we only shorten the filter text if the user is not including the namespaces in their - # typing - filter_text.delete_prefix!(prefix) unless incomplete_name.start_with?(prefix) - end - - # When using a top level constant reference (e.g.: `::Bar`), the editor includes the `::` as part of the filter. - # For these top level references, we need to include the `::` as part of the filter text or else it won't match - # the right entries in the index - Interface::CompletionItem.new( - label: real_name, - filter_text: filter_text, - text_edit: Interface::TextEdit.new( - range: range_from_node(node), - new_text: insertion_text, - ), - kind: kind, - label_details: Interface::CompletionItemLabelDetails.new( - description: entries.map(&:file_name).join(","), - ), - documentation: markdown_from_index_entries(real_name, entries), - ) + dispatcher = Prism::Dispatcher.new + listeners = [Listeners::Completion.new(index, nesting, dispatcher)] + super(listeners) + dispatcher.dispatch_once(target) end - # Check if there are any conflicting names for `entry_name`, which would require us to use a top level reference. - # For example: - # - # ```ruby - # class Bar; end - # - # module Foo - # class Bar; end - # - # # in this case, the completion for `Bar` conflicts with `Foo::Bar`, so we can't suggest `Bar` as the - # # completion, but instead need to suggest `::Bar` - # B - # end - # ``` - sig { params(entry_name: String).returns(T::Boolean) } - def top_level?(entry_name) - @nesting.length.downto(0).each do |i| - prefix = T.must(@nesting[0...i]).join("::") - full_name = prefix.empty? ? entry_name : "#{prefix}::#{entry_name}" - next if full_name == entry_name - - return true if @index[full_name] - end - - false + sig { override.returns(ResponseType) } + def response + @listeners.flat_map(&:response) end end end