diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb index 2995e065e6..4c55e30c41 100644 --- a/lib/ruby_lsp/listeners/completion.rb +++ b/lib/ruby_lsp/listeners/completion.rb @@ -50,7 +50,7 @@ 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, @@ -58,7 +58,8 @@ def initialize( # rubocop:disable Metrics/ParameterLists sorbet_level, dispatcher, uri, - trigger_character + trigger_character, + char_position ) @response_builder = response_builder @global_state = global_state @@ -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, @@ -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 @@ -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)) diff --git a/lib/ruby_lsp/requests/completion.rb b/lib/ruby_lsp/requests/completion.rb index 18d2c26fac..420552e1a3 100644 --- a/lib/ruby_lsp/requests/completion.rb +++ b/lib/ruby_lsp/requests/completion.rb @@ -13,7 +13,7 @@ class << self def provider Interface::CompletionOptions.new( resolve_provider: true, - trigger_characters: ["/", "\"", "'", ":", "@", ".", "=", "<", "$"], + trigger_characters: ["/", "\"", "'", ":", "@", ".", "=", "<", "$", "(", ","], completion_item: { labelDetailsSupport: true, }, @@ -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| diff --git a/test/requests/completion_test.rb b/test/requests/completion_test.rb index fac94acdc6..d91d79f62b 100644 --- a/test/requests/completion_test.rb +++ b/test/requests/completion_test.rb @@ -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: "], 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: "], 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: "], 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: "], result.map(&:label)) + end + end + private def with_file_structure(server, &block)