Skip to content

Commit

Permalink
Provide features on singletons (#2188)
Browse files Browse the repository at this point in the history
* Provide singleton context go to definition

* Provide singleton context hover

* Provide singleton context completion

* Provide singleton context signature help

* Handle singleton contexts properly for completion resolve

* Extract parameter presentation into entry
  • Loading branch information
vinistock committed Jun 18, 2024
1 parent cb10eb9 commit 6172892
Show file tree
Hide file tree
Showing 14 changed files with 382 additions and 35 deletions.
6 changes: 6 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ def initialize(name, file_path, location, comments, visibility, owner) # rubocop

sig { abstract.returns(T::Array[Parameter]) }
def parameters; end

# Returns a string with the decorated names of the parameters of this member. E.g.: `(a, b = 1, c: 2)`
sig { returns(String) }
def decorated_parameters
"(#{parameters.map(&:decorated_name).join(", ")})"
end
end

class Accessor < Member
Expand Down
9 changes: 4 additions & 5 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,12 @@ def fuzzy_search(query)
results.flat_map(&:first)
end

sig { params(name: String, receiver_name: String).returns(T::Array[Entry]) }
sig { params(name: T.nilable(String), receiver_name: String).returns(T::Array[Entry]) }
def method_completion_candidates(name, receiver_name)
ancestors = linearized_ancestors_of(receiver_name)
candidates = prefix_search(name).flatten
candidates.select! do |entry|
entry.is_a?(RubyIndexer::Entry::Member) && ancestors.any?(entry.owner&.name)
end

candidates = name ? prefix_search(name).flatten : @entries.values.flatten
candidates.select! { |entry| entry.is_a?(Entry::Member) && ancestors.any?(entry.owner&.name) }
candidates
end

Expand Down
59 changes: 49 additions & 10 deletions lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@ class Completion
typechecker_enabled: T::Boolean,
dispatcher: Prism::Dispatcher,
uri: URI::Generic,
trigger_character: T.nilable(String),
).void
end
def initialize(response_builder, global_state, node_context, typechecker_enabled, dispatcher, uri) # rubocop:disable Metrics/ParameterLists
def initialize( # rubocop:disable Metrics/ParameterLists
response_builder,
global_state,
node_context,
typechecker_enabled,
dispatcher,
uri,
trigger_character
)
@response_builder = response_builder
@global_state = global_state
@index = T.let(global_state.index, RubyIndexer::Index)
@type_inferrer = T.let(global_state.type_inferrer, TypeInferrer)
@node_context = node_context
@typechecker_enabled = typechecker_enabled
@uri = uri
@trigger_character = trigger_character

dispatcher.register(
self,
Expand Down Expand Up @@ -107,7 +118,7 @@ def on_call_node_enter(node)
when "require_relative"
complete_require_relative(node)
else
complete_self_receiver_method(node, name) if !@typechecker_enabled && self_receiver?(node)
complete_methods(node, name) unless @typechecker_enabled
end
end

Expand Down Expand Up @@ -192,7 +203,10 @@ def constant_path_completion(name, range)

sig { params(name: String, location: Prism::Location).void }
def handle_instance_variable_completion(name, location)
@index.instance_variable_completion_candidates(name, @node_context.fully_qualified_name).each do |entry|
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

@index.instance_variable_completion_candidates(name, type).each do |entry|
variable_name = entry.name

@response_builder << Interface::CompletionItem.new(
Expand Down Expand Up @@ -257,15 +271,40 @@ def complete_require_relative(node)
end

sig { params(node: Prism::CallNode, name: String).void }
def complete_self_receiver_method(node, name)
receiver_entries = @index[@node_context.fully_qualified_name]
return unless receiver_entries
def complete_methods(node, name)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

receiver = T.must(receiver_entries.first)
# When the trigger character is a dot, Prism matches the name of the call node to whatever is next in the source
# code, leading to us searching for the wrong name. What we want to do instead is show every available method
# when dot is pressed
method_name = @trigger_character == "." ? nil : name

@index.method_completion_candidates(name, receiver.name).each do |entry|
@response_builder << build_method_completion(T.cast(entry, RubyIndexer::Entry::Member), node)
range = if method_name
range_from_location(T.must(node.message_loc))
else
loc = T.must(node.call_operator_loc)
Interface::Range.new(
start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column + 1),
end: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column + 1),
)
end

@index.method_completion_candidates(method_name, type).each do |entry|
entry_name = entry.name

@response_builder << Interface::CompletionItem.new(
label: entry_name,
filter_text: entry_name,
text_edit: Interface::TextEdit.new(range: range, new_text: entry_name),
kind: Constant::CompletionItemKind::METHOD,
data: {
owner_name: T.cast(entry, RubyIndexer::Entry::Member).owner&.name,
},
)
end
rescue RubyIndexer::Index::NonExistingNamespaceError
# We have not indexed this namespace, so we can't provide any completions
end

sig do
Expand All @@ -283,7 +322,7 @@ def build_method_completion(entry, node)
text_edit: Interface::TextEdit.new(range: range_from_location(T.must(node.message_loc)), new_text: name),
kind: Constant::CompletionItemKind::METHOD,
label_details: Interface::CompletionItemLabelDetails.new(
detail: "(#{entry.parameters.map(&:decorated_name).join(", ")})",
detail: entry.decorated_parameters,
description: entry.file_name,
),
documentation: Interface::MarkupContent.new(
Expand Down
18 changes: 11 additions & 7 deletions lib/ruby_lsp/listeners/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, ty
@response_builder = response_builder
@global_state = global_state
@index = T.let(global_state.index, RubyIndexer::Index)
@type_inferrer = T.let(global_state.type_inferrer, TypeInferrer)
@uri = uri
@node_context = node_context
@typechecker_enabled = typechecker_enabled
Expand All @@ -48,7 +49,7 @@ def on_call_node_enter(node)
message = node.message
return unless message

handle_method_definition(message, self_receiver?(node))
handle_method_definition(message, @type_inferrer.infer_receiver_type(@node_context))
end

sig { params(node: Prism::StringNode).void }
Expand All @@ -70,7 +71,7 @@ def on_block_argument_node_enter(node)
value = expression.value
return unless value

handle_method_definition(value, false)
handle_method_definition(value, nil)
end

sig { params(node: Prism::ConstantPathNode).void }
Expand Down Expand Up @@ -123,7 +124,10 @@ def on_instance_variable_target_node_enter(node)

sig { params(name: String).void }
def handle_instance_variable_definition(name)
entries = @index.resolve_instance_variable(name, @node_context.fully_qualified_name)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

entries = @index.resolve_instance_variable(name, type)
return unless entries

entries.each do |entry|
Expand All @@ -141,10 +145,10 @@ def handle_instance_variable_definition(name)
# If by any chance we haven't indexed the owner, then there's no way to find the right declaration
end

sig { params(message: String, self_receiver: T::Boolean).void }
def handle_method_definition(message, self_receiver)
methods = if self_receiver
@index.resolve_method(message, @node_context.fully_qualified_name)
sig { params(message: String, receiver_type: T.nilable(String)).void }
def handle_method_definition(message, receiver_type)
methods = if receiver_type
@index.resolve_method(message, receiver_type)
else
# If the method doesn't have a receiver, then we provide a few candidates to jump to
# But we don't want to provide too many candidates, as it can be overwhelming
Expand Down
15 changes: 10 additions & 5 deletions lib/ruby_lsp/listeners/hover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def initialize(response_builder, global_state, uri, node_context, dispatcher, ty
@response_builder = response_builder
@global_state = global_state
@index = T.let(global_state.index, RubyIndexer::Index)
@type_inferrer = T.let(global_state.type_inferrer, TypeInferrer)
@path = T.let(uri.to_standardized_path, T.nilable(String))
@node_context = node_context
@typechecker_enabled = typechecker_enabled
Expand Down Expand Up @@ -95,8 +96,6 @@ def on_constant_path_node_enter(node)

sig { params(node: Prism::CallNode).void }
def on_call_node_enter(node)
return unless self_receiver?(node)

if @path && File.basename(@path) == GEMFILE_NAME && node.name == :gem
generate_gem_hover(node)
return
Expand All @@ -107,10 +106,13 @@ def on_call_node_enter(node)
message = node.message
return unless message

methods = @index.resolve_method(message, @node_context.fully_qualified_name)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

methods = @index.resolve_method(message, type)
return unless methods

title = "#{message}(#{T.must(methods.first).parameters.map(&:decorated_name).join(", ")})"
title = "#{message}#{T.must(methods.first).decorated_parameters}"

categorized_markdown_from_index_entries(title, methods).each do |category, content|
@response_builder.push(content, category: category)
Expand Down Expand Up @@ -151,7 +153,10 @@ def on_instance_variable_target_node_enter(node)

sig { params(name: String).void }
def handle_instance_variable_hover(name)
entries = @index.resolve_instance_variable(name, @node_context.fully_qualified_name)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

entries = @index.resolve_instance_variable(name, type)
return unless entries

categorized_markdown_from_index_entries(name, entries).each do |category, content|
Expand Down
7 changes: 5 additions & 2 deletions lib/ruby_lsp/listeners/signature_help.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,22 @@ def initialize(response_builder, global_state, node_context, dispatcher, typeche
@response_builder = response_builder
@global_state = global_state
@index = T.let(global_state.index, RubyIndexer::Index)
@type_inferrer = T.let(global_state.type_inferrer, TypeInferrer)
@node_context = node_context
dispatcher.register(self, :on_call_node_enter)
end

sig { params(node: Prism::CallNode).void }
def on_call_node_enter(node)
return if @typechecker_enabled
return unless self_receiver?(node)

message = node.message
return unless message

methods = @index.resolve_method(message, @node_context.fully_qualified_name)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

methods = @index.resolve_method(message, type)
return unless methods

target_method = methods.first
Expand Down
9 changes: 5 additions & 4 deletions lib/ruby_lsp/requests/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class << self
def provider
Interface::CompletionOptions.new(
resolve_provider: true,
trigger_characters: ["/", "\"", "'", ":", "@"],
trigger_characters: ["/", "\"", "'", ":", "@", "."],
completion_item: {
labelDetailsSupport: true,
},
Expand All @@ -48,18 +48,18 @@ def provider
params(
document: Document,
global_state: GlobalState,
position: T::Hash[Symbol, T.untyped],
params: T::Hash[Symbol, T.untyped],
typechecker_enabled: T::Boolean,
dispatcher: Prism::Dispatcher,
).void
end
def initialize(document, global_state, position, typechecker_enabled, dispatcher)
def initialize(document, global_state, params, typechecker_enabled, dispatcher)
super()
@target = T.let(nil, T.nilable(Prism::Node))
@dispatcher = dispatcher
# 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
char_position = document.create_scanner.find_char_position(params[:position]) - 1
node_context = document.locate(
document.tree,
char_position,
Expand Down Expand Up @@ -87,6 +87,7 @@ def initialize(document, global_state, position, typechecker_enabled, dispatcher
typechecker_enabled,
dispatcher,
document.uri,
params.dig(:context, :triggerCharacter),
)

Addon.addons.each do |addon|
Expand Down
8 changes: 8 additions & 0 deletions lib/ruby_lsp/requests/completion_resolve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,16 @@ def perform
end
end

first_entry = T.must(entries.first)

if first_entry.is_a?(RubyIndexer::Entry::Member)
detail = first_entry.decorated_parameters
label = "#{label}#{first_entry.decorated_parameters}"
end

@item[:labelDetails] = Interface::CompletionItemLabelDetails.new(
description: entries.take(MAX_DOCUMENTATION_ENTRIES).map(&:file_name).join(","),
detail: detail,
)

@item[:documentation] = Interface::MarkupContent.new(
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ def text_document_completion(message)
response: Requests::Completion.new(
document,
@global_state,
params[:position],
params,
typechecker_enabled?(document),
dispatcher,
).perform,
Expand Down
27 changes: 27 additions & 0 deletions test/requests/completion_resolve_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,31 @@ def initialize
assert_match(/Bar/, result[:documentation].value)
end
end

def test_inserts_method_parameters_in_label_details
source = +<<~RUBY
class Bar
def foo(a, b, c)
end
def bar
f
end
end
RUBY

with_server(source, stub_no_typechecker: true) do |server, _uri|
existing_item = {
label: "foo",
kind: RubyLsp::Constant::CompletionItemKind::METHOD,
data: { owner_name: "Bar" },
}

server.process_message(id: 1, method: "completionItem/resolve", params: existing_item)

result = server.pop_response.response
assert_equal("(a, b, c)", result[:labelDetails].detail)
assert_match("(a, b, c)", result[:documentation].value)
end
end
end
Loading

0 comments on commit 6172892

Please sign in to comment.