Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
require "ruby_lsp/rubydex/declaration"
require "ruby_lsp/rubydex/definition"
require "ruby_lsp/rubydex/reference"
require "ruby_lsp/rubydex/signature"

require "ruby-lsp"
require "ruby_lsp/base_server"
Expand Down
32 changes: 19 additions & 13 deletions lib/ruby_lsp/listeners/signature_help.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def initialize(response_builder, global_state, node_context, dispatcher, sorbet_
@sorbet_level = sorbet_level
@response_builder = response_builder
@global_state = global_state
@index = global_state.index #: RubyIndexer::Index
@graph = global_state.graph #: Rubydex::Graph
@type_inferrer = global_state.type_inferrer #: TypeInferrer
@node_context = node_context
dispatcher.register(self, :on_call_node_enter)
Expand All @@ -27,18 +27,24 @@ def on_call_node_enter(node)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

methods = @index.resolve_method(message, type.name)
return unless methods
owner = @graph[type.name]
return unless owner.is_a?(Rubydex::Namespace)

target_method = methods.first
return unless target_method
target_method = owner.find_member("#{message}()")
return unless target_method.is_a?(Rubydex::Method)

signatures = target_method.signatures
signatures = target_method.definitions.flat_map do |defn|
case defn
when Rubydex::MethodDefinition, Rubydex::MethodAliasDefinition
defn.signatures
else
[]
end
end

# If the method doesn't have any parameters, there's no need to show signature help
# If the method doesn't have any signatures, there's nothing to show
return if signatures.empty?

name = target_method.name
title = +""

extra_links = if type.is_a?(TypeInferrer::GuessedType)
Expand All @@ -49,7 +55,7 @@ def on_call_node_enter(node)
active_signature, active_parameter = determine_active_signature_and_parameter(node, signatures)

signature_help = Interface::SignatureHelp.new(
signatures: generate_signatures(signatures, name, methods, title, extra_links),
signatures: generate_signatures(signatures, message, target_method, title, extra_links),
active_signature: active_signature,
active_parameter: active_parameter,
)
Expand All @@ -58,7 +64,7 @@ def on_call_node_enter(node)

private

#: (Prism::CallNode node, Array[RubyIndexer::Entry::Signature] signatures) -> [Integer, Integer]
#: (Prism::CallNode node, Array[Rubydex::Signature] signatures) -> [Integer, Integer]
def determine_active_signature_and_parameter(node, signatures)
arguments_node = node.arguments
arguments = arguments_node&.arguments || []
Expand Down Expand Up @@ -86,15 +92,15 @@ def determine_active_signature_and_parameter(node, signatures)
[active_sig_index, active_parameter]
end

#: (Array[RubyIndexer::Entry::Signature] signatures, String method_name, Array[RubyIndexer::Entry] methods, String title, String? extra_links) -> Array[Interface::SignatureInformation]
def generate_signatures(signatures, method_name, methods, title, extra_links)
#: (Array[Rubydex::Signature] signatures, String method_name, Rubydex::Method method, String title, String? extra_links) -> Array[Interface::SignatureInformation]
def generate_signatures(signatures, method_name, method, title, extra_links)
signatures.map do |signature|
Interface::SignatureInformation.new(
label: "#{method_name}(#{signature.format})",
parameters: signature.parameters.map { |param| Interface::ParameterInformation.new(label: param.name) },
documentation: Interface::MarkupContent.new(
kind: "markdown",
value: markdown_from_index_entries(title, methods, extra_links: extra_links),
value: markdown_from_definitions(title, method.definitions, extra_links: extra_links),
),
)
end
Expand Down
107 changes: 107 additions & 0 deletions lib/ruby_lsp/rubydex/signature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# typed: strict
# frozen_string_literal: true

module Rubydex
class Signature
# Returns a string with the decorated names of the parameters of this signature, e.g.
# `(a, b = <default>, *c, d, e:, f: <default>, **g, &h)`.
#: () -> String
def format
parameters.map { |param| decorated_name(param) }.join(", ")
end

# Returns `true` if the given call node arguments array matches this signature. The matching is intentionally lenient
# because this method is used to detect which overload should be displayed in signature help while the user is still
# typing the call. We prefer returning `true` for situations that cannot be analyzed statically (e.g. presence of
# splats, keyword splats, forwarding) and accept missing arguments since the user may not be done typing yet.
#: (Array[Prism::Node] arguments) -> bool
def matches?(arguments)
min_pos = 0
max_pos = 0 #: (Integer | Float)
names = []
has_forward = false #: bool
has_keyword_rest = false #: bool

parameters.each do |param|
case param
when PositionalParameter, PostParameter
min_pos += 1
max_pos += 1
when OptionalPositionalParameter
max_pos += 1
when RestPositionalParameter
max_pos = Float::INFINITY
when ForwardParameter
max_pos = Float::INFINITY
has_forward = true
when KeywordParameter, OptionalKeywordParameter
names << param.name
when RestKeywordParameter
has_keyword_rest = true
end
end

keyword_hash_nodes, positional_args = arguments.partition { |arg| arg.is_a?(Prism::KeywordHashNode) }
keyword_args = keyword_hash_nodes.first #: as Prism::KeywordHashNode?
&.elements
forwarding_arguments, positionals = positional_args.partition do |arg|
arg.is_a?(Prism::ForwardingArgumentsNode)
end

return true if has_forward && min_pos == 0

# If the only argument passed is a forwarding argument, then anything will match
(positionals.empty? && forwarding_arguments.any?) ||
(
positional_arguments_match?(positionals, forwarding_arguments, keyword_args, min_pos, max_pos) &&
(has_forward || has_keyword_rest || keyword_arguments_match?(keyword_args, names))
)
end

private

#: (Parameter) -> String
def decorated_name(param)
case param
when OptionalPositionalParameter
"#{param.name} = <default>"
when RestPositionalParameter
"*#{param.name}"
when KeywordParameter
"#{param.name}:"
when OptionalKeywordParameter
"#{param.name}: <default>"
when RestKeywordParameter
"**#{param.name}"
when BlockParameter
"&#{param.name}"
else
param.name.to_s
end
end

#: (Array[Prism::Node] positional_args, Array[Prism::Node] forwarding_arguments, Array[Prism::Node]? keyword_args, Integer min_pos, (Integer | Float) max_pos) -> bool
def positional_arguments_match?(positional_args, forwarding_arguments, keyword_args, min_pos, max_pos)
(min_pos > 0 && positional_args.any? { |arg| arg.is_a?(Prism::SplatNode) }) ||
(min_pos - positional_args.length > 0 && keyword_args&.any? { |arg| arg.is_a?(Prism::AssocSplatNode) }) ||
(min_pos - positional_args.length > 0 && forwarding_arguments.any?) ||
(min_pos > 0 && positional_args.length <= max_pos) ||
(min_pos == 0 && positional_args.empty?)
end

#: (Array[Prism::Node]? args, Array[Symbol] names) -> bool
def keyword_arguments_match?(args, names)
return true unless args
return true if args.any? { |arg| arg.is_a?(Prism::AssocSplatNode) }

arg_names = args.filter_map do |arg|
next unless arg.is_a?(Prism::AssocNode)

key = arg.key
key.value&.to_sym if key.is_a?(Prism::SymbolNode)
end

(arg_names - names).empty?
end
end
end
41 changes: 26 additions & 15 deletions test/requests/signature_help_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -397,64 +397,75 @@ def subscribe!(news_letter)
end

def test_automatically_detects_active_overload
rbs = <<~RBS
class Foo
def step: (?Integer limit, ?Integer step) { (Integer) -> void } -> void
| (?by: Integer, ?to: Integer) { (Integer) -> void } -> void
end
RBS
rbs_uri = URI::Generic.from_path(path: "/fake/path/foo.rbs").to_s

# First step overload: just a block
source = <<~RUBY
5.step()
Foo.new.step()
RUBY

with_server(source) do |server, uri|
index = server.instance_variable_get(:@global_state).index
RubyIndexer::RBSIndexer.new(index).index_ruby_core
graph = server.global_state.graph
graph.index_source(rbs_uri, rbs, "rbs")
graph.resolve

server.process_message(id: 1, method: "textDocument/signatureHelp", params: {
textDocument: { uri: uri },
position: { line: 0, character: 7 },
position: { line: 0, character: 13 },
context: {},
})

result = server.pop_response.response
signature = result.signatures[result.active_signature]
assert_equal("step(limit = <default>, step = <default>, &<anonymous block>)", signature.label)
assert_equal("step(limit = <default>, step = <default>, &block)", signature.label)
end

# Second step overload: with positional arguments
source = <<~RUBY
5.step(1)
Foo.new.step(1)
RUBY

with_server(source) do |server, uri|
index = server.instance_variable_get(:@global_state).index
RubyIndexer::RBSIndexer.new(index).index_ruby_core
graph = server.global_state.graph
graph.index_source(rbs_uri, rbs, "rbs")
graph.resolve

server.process_message(id: 2, method: "textDocument/signatureHelp", params: {
textDocument: { uri: uri },
position: { line: 0, character: 8 },
position: { line: 0, character: 14 },
context: {},
})

result = server.pop_response.response
signature = result.signatures[result.active_signature]
assert_equal("step(limit = <default>, step = <default>, &<anonymous block>)", signature.label)
assert_equal("step(limit = <default>, step = <default>, &block)", signature.label)
end

# Third step overload: with keyword arguments
source = <<~RUBY
5.step(to: 5)
Foo.new.step(to: 5)
RUBY

with_server(source) do |server, uri|
index = server.instance_variable_get(:@global_state).index
RubyIndexer::RBSIndexer.new(index).index_ruby_core
graph = server.global_state.graph
graph.index_source(rbs_uri, rbs, "rbs")
graph.resolve

server.process_message(id: 2, method: "textDocument/signatureHelp", params: {
textDocument: { uri: uri },
position: { line: 0, character: 8 },
position: { line: 0, character: 14 },
context: {},
})

result = server.pop_response.response
signature = result.signatures[result.active_signature]
assert_equal("step(by: <default>, to: <default>, &<anonymous block>)", signature.label)
assert_equal("step(by: <default>, to: <default>, &block)", signature.label)
end
end
end
Loading