Skip to content

Completion for keyword arguments #3397

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
140 changes: 138 additions & 2 deletions lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,16 @@ class Completion
"__LINE__",
].freeze

#: (ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, GlobalState global_state, NodeContext node_context, RubyDocument::SorbetLevel sorbet_level, Prism::Dispatcher dispatcher, URI::Generic uri, String? trigger_character) -> void
#: (ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, GlobalState global_state, NodeContext node_context, RubyDocument::SorbetLevel sorbet_level, Prism::Dispatcher dispatcher, URI::Generic uri, String? trigger_character, Integer char_position) -> void
def initialize( # rubocop:disable Metrics/ParameterLists
response_builder,
global_state,
node_context,
sorbet_level,
dispatcher,
uri,
trigger_character
trigger_character,
char_position
)
@response_builder = response_builder
@global_state = global_state
Expand All @@ -68,6 +69,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists
@sorbet_level = sorbet_level
@uri = uri
@trigger_character = trigger_character
@char_position = char_position

dispatcher.register(
self,
Expand Down Expand Up @@ -169,6 +171,11 @@ def on_call_node_enter(node)
end
end

if ["(", ","].include?(@trigger_character)
complete_keyword_arguments(node)
return
end

name = node.message
return unless name

Expand Down Expand Up @@ -525,10 +532,139 @@ def complete_methods(node, name)
},
)
end

# In a situation like this:
# foo(aaa: 1, b)
# ^
# when we type `b`, it triggers completion for b,
# so we provide keyword arguments completion for `foo` using `b` as a filter prefix
if @node_context.parent.is_a?(Prism::CallNode) && method_name
call_node = T.cast(@node_context.parent, Prism::CallNode)
candidates = keyword_argument_completion_candidates(call_node, method_name)
candidates.each do |param|
build_keyword_argument_completion_item(param, range)
end
end
rescue RubyIndexer::Index::NonExistingNamespaceError
# We have not indexed this namespace, so we can't provide any completions
end

#: (Prism::CallNode call_node, String? filter) -> void
def complete_keyword_arguments(call_node, filter = nil)
candidates = keyword_argument_completion_candidates(call_node, filter)

range =
case @trigger_character
when "("
opening_loc = call_node.opening_loc
if opening_loc
Interface::Range.new(
start: Interface::Position.new(
line: opening_loc.start_line - 1,
character: opening_loc.start_column + 1,
),
end: Interface::Position.new(line: opening_loc.start_line - 1, character: opening_loc.start_column + 1),
)
end
when ","
arguments = call_node.arguments
if arguments
nearest_argument = arguments.arguments.flat_map do
_1.is_a?(Prism::KeywordHashNode) ? _1.elements : _1
end.find do |argument|
(argument.location.start_offset..argument.location.end_offset).cover?(@char_position)
end
location = nearest_argument&.location || arguments.location
Interface::Range.new(
start: Interface::Position.new(
line: location.end_line - 1,
character: location.end_column + 1,
),
end: Interface::Position.new(
line: location.end_line - 1,
character: location.end_column + 1,
),
)
end
end
return unless range

candidates.each do |param|
build_keyword_argument_completion_item(param, range)
end
end

#: (RubyIndexer::Entry::KeywordParameter | RubyIndexer::Entry::OptionalKeywordParameter param, Interface::Range range) -> void
def build_keyword_argument_completion_item(param, range)
new_text =
param.is_a?(RubyIndexer::Entry::KeywordParameter) ? param.decorated_name : "#{param.name}:".to_sym
new_text = @trigger_character == "," ? " #{new_text} " : "#{new_text} "
@response_builder << Interface::CompletionItem.new(
label: param.decorated_name,
text_edit: Interface::TextEdit.new(range: range, new_text: new_text),
kind: Constant::CompletionItemKind::PROPERTY,
)
end

#: (Prism::CallNode call_node, String? filter) -> Array[RubyIndexer::Entry::KeywordParameter | RubyIndexer::Entry::OptionalKeywordParameter]
def keyword_argument_completion_candidates(call_node, filter = nil)
method = resolve_method(call_node, call_node.message) if call_node
return [] unless method

signature = method.signatures.first
return [] unless signature

arguments = call_node.arguments&.arguments || []
keyword_arguments = arguments.find { _1.is_a?(Prism::KeywordHashNode) }

arg_names =
if keyword_arguments
keyword_arguments = T.cast(keyword_arguments, Prism::KeywordHashNode)
T.cast(
keyword_arguments.elements.select { _1.is_a?(Prism::AssocNode) },
T::Array[Prism::AssocNode],
).map do |arg|
key = arg.key
arg_name =
case key
when Prism::StringNode then key.content
when Prism::SymbolNode then key.value
when Prism::CallNode then key.name
end

arg_name&.to_sym
end.compact
else
[]
end

candidates = []
signature.parameters.each do |param|
unless param.is_a?(RubyIndexer::Entry::KeywordParameter) ||
param.is_a?(RubyIndexer::Entry::OptionalKeywordParameter)
next
end
# the argument is already provided
next if arg_names.include?(param.name)
# the argument is being typed halfway
next if filter && !param.name.start_with?(filter)

candidates << param
end

candidates
end

#: (Prism::CallNode node, String? name) -> (RubyIndexer::Entry::Member | RubyIndexer::Entry::MethodAlias)?
def resolve_method(node, name)
return unless name

type = @type_inferrer.send(:infer_receiver_for_call_node, node, @node_context)
return unless type

@index.resolve_method(name, type.name)&.first
end

#: (Prism::CallNode node, String name) -> void
def add_local_completions(node, name)
range = range_from_location(T.must(node.message_loc))
Expand Down
3 changes: 2 additions & 1 deletion lib/ruby_lsp/requests/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class << self
def provider
Interface::CompletionOptions.new(
resolve_provider: true,
trigger_characters: ["/", "\"", "'", ":", "@", ".", "=", "<", "$"],
trigger_characters: ["/", "\"", "'", ":", "@", ".", "=", "<", "$", "(", ","],
completion_item: {
labelDetailsSupport: true,
},
Expand Down Expand Up @@ -71,6 +71,7 @@ def initialize(document, global_state, params, sorbet_level, dispatcher)
dispatcher,
document.uri,
params.dig(:context, :triggerCharacter),
char_position,
)

Addon.addons.each do |addon|
Expand Down
59 changes: 59 additions & 0 deletions test/requests/completion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,65 @@ def baz
end
end

def test_completion_for_keyword_arguments
source = +<<~RUBY
class Foo
def foo(arg1, aaa:, bbb:, abc: 1)
end
end

class Bar
def bar(xxx:, yyy:, xyz: 1)
end
end

baz = 2
Foo.new.foo(
Foo.new.foo(baz, bbb: 1,
Foo.new.foo(
baz,
aaa: Bar.new.bar(
baz,
),
bbb: 3
)
RUBY

with_server(source, stub_no_typechecker: true) do |server, uri|
server.process_message(id: 1, method: "textDocument/completion", params: {
textDocument: { uri: uri },
position: { line: 11, character: 12 },
context: { triggerCharacter: "(" },
})
result = server.pop_response.response
assert_equal([:"aaa:", :"bbb:", :"abc: <default>"], result.map(&:label))

server.process_message(id: 1, method: "textDocument/completion", params: {
textDocument: { uri: uri },
position: { line: 12, character: 24 },
context: { triggerCharacter: "," },
})
result = server.pop_response.response
assert_equal([:"aaa:", :"abc: <default>"], result.map(&:label))

server.process_message(id: 1, method: "textDocument/completion", params: {
textDocument: { uri: uri },
position: { line: 16, character: 8 },
context: { triggerCharacter: "," },
})
result = server.pop_response.response
assert_equal([:"xxx:", :"yyy:", :"xyz: <default>"], result.map(&:label))

server.process_message(id: 1, method: "textDocument/completion", params: {
textDocument: { uri: uri },
position: { line: 17, character: 4 },
context: { triggerCharacter: "," },
})
result = server.pop_response.response
assert_equal([:"abc: <default>"], result.map(&:label))
end
end

private

def with_file_structure(server, &block)
Expand Down
Loading