Skip to content

Commit

Permalink
Add support for completionItem/resolve (#1798)
Browse files Browse the repository at this point in the history
* Add support for completionItem/resolve

This dramatically improves editor performance in certain situations by
omitting details and documentation from completion results. Instead, the
documentation and details are provided when the client resolves the
selected completion item.

* Add some documentation
  • Loading branch information
naveg authored Mar 26, 2024
1 parent 56d648b commit 4ba0b50
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 13 deletions.
7 changes: 0 additions & 7 deletions lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,6 @@ def build_entry_completion(real_name, incomplete_name, node, entries, top_level)
new_text: insertion_text,
),
kind: kind,
label_details: Interface::CompletionItemLabelDetails.new(
description: entries.map(&:file_name).join(","),
),
documentation: Interface::MarkupContent.new(
kind: "markdown",
value: markdown_from_index_entries(real_name, entries),
),
)
end

Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_lsp/requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module RubyLsp
# - [DocumentHighlight](rdoc-ref:RubyLsp::Requests::DocumentHighlight)
# - [InlayHint](rdoc-ref:RubyLsp::Requests::InlayHints)
# - [Completion](rdoc-ref:RubyLsp::Requests::Completion)
# - [CompletionResolve](rdoc-ref:RubyLsp::Requests::CompletionResolve)
# - [CodeLens](rdoc-ref:RubyLsp::Requests::CodeLens)
# - [Definition](rdoc-ref:RubyLsp::Requests::Definition)
# - [ShowSyntaxTree](rdoc-ref:RubyLsp::Requests::ShowSyntaxTree)
Expand All @@ -40,6 +41,7 @@ module Requests
autoload :DocumentHighlight, "ruby_lsp/requests/document_highlight"
autoload :InlayHints, "ruby_lsp/requests/inlay_hints"
autoload :Completion, "ruby_lsp/requests/completion"
autoload :CompletionResolve, "ruby_lsp/requests/completion_resolve"
autoload :CodeLens, "ruby_lsp/requests/code_lens"
autoload :Definition, "ruby_lsp/requests/definition"
autoload :ShowSyntaxTree, "ruby_lsp/requests/show_syntax_tree"
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_lsp/requests/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class << self
sig { returns(Interface::CompletionOptions) }
def provider
Interface::CompletionOptions.new(
resolve_provider: false,
resolve_provider: true,
trigger_characters: ["/", "\"", "'"],
completion_item: {
labelDetailsSupport: true,
Expand Down
48 changes: 48 additions & 0 deletions lib/ruby_lsp/requests/completion_resolve.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Requests
# The [completionItem/resolve](https://microsoft.github.io/language-server-protocol/specification#completionItem_resolve)
# request provides additional information about the currently selected completion. Specifically, the `labelDetails`
# and `documentation` fields are provided, which are omitted from the completion items returned by
# `textDocument/completion`.
#
# The `labelDetails` field lists the files where the completion item is defined, and the `documentation` field
# includes any available documentation for those definitions.
#
# At most 10 definitions are included, to ensure low latency during request processing and rendering the completion
# item.
class CompletionResolve < Request
extend T::Sig
include Requests::Support::Common

# set a limit on the number of documentation entries returned, to avoid rendering performance issues
# https://github.com/Shopify/ruby-lsp/pull/1798
MAX_DOCUMENTATION_ENTRIES = 10

sig { params(index: RubyIndexer::Index, item: T::Hash[Symbol, T.untyped]).void }
def initialize(index, item)
super()
@index = index
@item = item
end

sig { override.returns(Interface::CompletionItem) }
def perform
label = @item[:label]
entries = @index[label] || []
Interface::CompletionItem.new(
label: label,
label_details: Interface::CompletionItemLabelDetails.new(
description: entries.take(MAX_DOCUMENTATION_ENTRIES).map(&:file_name).join(","),
),
documentation: Interface::MarkupContent.new(
kind: "markdown",
value: markdown_from_index_entries(label, entries, MAX_DOCUMENTATION_ENTRIES),
),
)
end
end
end
end
21 changes: 16 additions & 5 deletions lib/ruby_lsp/requests/support/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,16 @@ def self_receiver?(node)
params(
title: String,
entries: T.any(T::Array[RubyIndexer::Entry], RubyIndexer::Entry),
max_entries: T.nilable(Integer),
).returns(T::Hash[Symbol, String])
end
def categorized_markdown_from_index_entries(title, entries)
def categorized_markdown_from_index_entries(title, entries, max_entries = nil)
markdown_title = "```ruby\n#{title}\n```"
definitions = []
content = +""
Array(entries).each do |entry|
entries = Array(entries)
entries_to_format = max_entries ? entries.take(max_entries) : entries
entries_to_format.each do |entry|
loc = entry.location

# We always handle locations as zero based. However, for file links in Markdown we need them to be one
Expand All @@ -108,9 +111,16 @@ def categorized_markdown_from_index_entries(title, entries)
content << "\n\n#{entry.comments.join("\n")}" unless entry.comments.empty?
end

additional_entries_text = if max_entries && entries.length > max_entries
additional = entries.length - max_entries
" | #{additional} other#{additional > 1 ? "s" : ""}"
else
""
end

{
title: markdown_title,
links: "**Definitions**: #{definitions.join(" | ")}",
links: "**Definitions**: #{definitions.join(" | ")}#{additional_entries_text}",
documentation: content,
}
end
Expand All @@ -119,10 +129,11 @@ def categorized_markdown_from_index_entries(title, entries)
params(
title: String,
entries: T.any(T::Array[RubyIndexer::Entry], RubyIndexer::Entry),
max_entries: T.nilable(Integer),
).returns(String)
end
def markdown_from_index_entries(title, entries)
categorized_markdown = categorized_markdown_from_index_entries(title, entries)
def markdown_from_index_entries(title, entries, max_entries = nil)
categorized_markdown = categorized_markdown_from_index_entries(title, entries, max_entries)

<<~MARKDOWN.chomp
#{categorized_markdown[:title]}
Expand Down
10 changes: 10 additions & 0 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def process_message(message)
text_document_diagnostic(message)
when "textDocument/completion"
text_document_completion(message)
when "completionItem/resolve"
text_document_completion_item_resolve(message)
when "textDocument/signatureHelp"
text_document_signature_help(message)
when "textDocument/definition"
Expand Down Expand Up @@ -545,6 +547,14 @@ def text_document_completion(message)
)
end

sig { params(message: T::Hash[Symbol, T.untyped]).void }
def text_document_completion_item_resolve(message)
send_message(Result.new(
id: message[:id],
response: Requests::CompletionResolve.new(@index, message[:params]).perform,
))
end

sig { params(message: T::Hash[Symbol, T.untyped]).void }
def text_document_signature_help(message)
params = message[:params]
Expand Down
41 changes: 41 additions & 0 deletions test/requests/completion_resolve_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# typed: true
# frozen_string_literal: true

require "test_helper"

class CompletionResolveTest < Minitest::Test
include RubyLsp::Requests::Support::Common

Interface = LanguageServer::Protocol::Interface
Constant = LanguageServer::Protocol::Constant

def test_completion_resolve_for_constant
stub_no_typechecker
source = +<<~RUBY
# This is a class that does things
class Foo
end
RUBY

with_server(source) do |server, uri|
server.process_message(id: 1, method: "completionItem/resolve", params: {
label: "Foo",
})

result = server.pop_response.response

expected = Interface::CompletionItem.new(
label: "Foo",
label_details: Interface::CompletionItemLabelDetails.new(
description: "fake.rb",
),
documentation: Interface::MarkupContent.new(
kind: "markdown",
value: markdown_from_index_entries("Foo", T.must(server.index["Foo"])),
),
)
assert_match(/This is a class that does things/, result.documentation.value)
assert_equal(expected.to_json, result.to_json)
end
end
end

0 comments on commit 4ba0b50

Please sign in to comment.