From 15c5ae05a22ef85898b877882121cf9c9a5093e2 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sun, 9 Jul 2023 01:49:03 +0900 Subject: [PATCH] Support completion for keyword arguments --- lib/steep/server/interaction_worker.rb | 11 ++++ lib/steep/services/completion_provider.rb | 67 +++++++++++++++++++++- sig/steep/services/completion_provider.rbs | 13 +++++ test/completion_provider_test.rb | 34 +++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/lib/steep/server/interaction_worker.rb b/lib/steep/server/interaction_worker.rb index e7b5aea53..72ea7eda7 100644 --- a/lib/steep/server/interaction_worker.rb +++ b/lib/steep/server/interaction_worker.rb @@ -361,6 +361,17 @@ def format_completion_item(item) new_text: item.identifier.to_s ) ) + when Services::CompletionProvider::KeywordArgumentItem + LSP::Interface::CompletionItem.new( + label: item.identifier.to_s, + kind: LSP::Constant::CompletionItemKind::FIELD, + label_details: LSP::Interface::CompletionItemLabelDetails.new(description: 'Keyword argument'), + documentation: LSPFormatter.markup_content { LSPFormatter.format_completion_docs(item) }, + text_edit: LSP::Interface::TextEdit.new( + range: range, + new_text: item.identifier.to_s + ) + ) when Services::CompletionProvider::TypeNameItem kind = case diff --git a/lib/steep/services/completion_provider.rb b/lib/steep/services/completion_provider.rb index fae148035..26e366f55 100644 --- a/lib/steep/services/completion_provider.rb +++ b/lib/steep/services/completion_provider.rb @@ -11,6 +11,7 @@ def -(size) Range = _ = Struct.new(:start, :end, keyword_init: true) InstanceVariableItem = _ = Struct.new(:identifier, :range, :type, keyword_init: true) + KeywordArgumentItem = _ = Struct.new(:identifier, :range, keyword_init: true) LocalVariableItem = _ = Struct.new(:identifier, :range, :type, keyword_init: true) ConstantItem = _ = Struct.new(:env, :identifier, :range, :type, :full_name, keyword_init: true) do # @implements ConstantItem @@ -267,7 +268,9 @@ def run(line:, column:) [] end else - [] + items = [] #: Array[item] + items_for_following_keyword_arguments(source_text, index: index, line: line, column: column, items: items) + items end end end @@ -302,7 +305,7 @@ def range_for(position, prefix: "") end def items_for_trigger(position:) - node, *_parents = source.find_nodes(line: position.line, column: position.column) + node, *parents = source.find_nodes(line: position.line, column: position.column) node ||= source.node return [] unless node @@ -318,6 +321,7 @@ def items_for_trigger(position:) method_items_for_receiver_type(context.self_type, include_private: true, prefix: prefix, position: position, items: items) local_variable_items_for_context(context, position: position, prefix: prefix, items: items) + keyword_argument_items_for_method(node: parents&.first, position: position, prefix: prefix, items: items) when node.type == :lvar && at_end?(position, of: node.loc) # foo ← (lvar) @@ -527,6 +531,30 @@ def items_for_rbs(position:, buffer:) items end + def items_for_following_keyword_arguments(text, index:, line:, column:, items:) + return if text[index - 1] !~ /[a-zA-Z0-9]/ + + text = text.dup + argname = [] #: Array[String] + while text[index - 1] =~ /[a-zA-Z0-9]/ + argname.unshift(text[index - 1] || '') + source_text[index - 1] = " " + index -= 1 + end + + begin + type_check!(source_text, line: line, column: column) + rescue Parser::SyntaxError + return + end + + node, *_parents = source.find_nodes(line: line, column: column) + if node && (node.type == :send || node.type == :csend) + position = Position.new(line: line, column: column) + keyword_argument_items_for_method(node: node, position: position, prefix: argname.join, items: items) + end + end + def method_items_for_receiver_type(type, include_private:, prefix:, position:, items:) range = range_for(position, prefix: prefix) context = typing.context_at(line: position.line, column: position.column) @@ -641,6 +669,41 @@ def instance_variable_items_for_context(context, position:, prefix:, items:) end end + def keyword_argument_items_for_method(node:, position:, prefix:, items:) + return unless node && (node.type == :send || node.type == :csend) + + call = typing.call_of(node: node) + case call + when TypeInference::MethodCall::Typed, TypeInference::MethodCall::Error + type = call.receiver_type + shape = subtyping.builder.shape( + type, + public_only: !node.children[0].nil?, + config: Interface::Builder::Config.new(self_type: type, class_type: nil, instance_type: nil, variable_bounds: {}) + ) + if shape + if method = shape.methods[call.method_name] + method.method_types.each.with_index do |method_type, i| + defn = method_type.method_decls.to_a[0]&.method_def + if defn + range = range_for(position, prefix: prefix) + kwargs = node.children[2...]&.find { |arg| arg.type == :kwargs }&.children || [] + used_kwargs = kwargs.filter_map { |arg| arg.type == :pair && arg.children.first.children.first } + + kwargs = defn.type.type.required_keywords.keys + defn.type.type.optional_keywords.keys + kwargs.each do |name| + if name.to_s.start_with?(prefix) && !used_kwargs.include?(name) + items << KeywordArgumentItem.new(identifier: "#{name}:", range: range) + end + end + end + end + end + end + end + end + + def index_for(string, line:, column:) index = 0 diff --git a/sig/steep/services/completion_provider.rbs b/sig/steep/services/completion_provider.rbs index 0ec1e233d..a52770cfb 100644 --- a/sig/steep/services/completion_provider.rbs +++ b/sig/steep/services/completion_provider.rbs @@ -33,6 +33,14 @@ module Steep def initialize: (identifier: Symbol, range: Range, type: AST::Types::t) -> void end + class KeywordArgumentItem + attr_reader identifier: String + + attr_reader range: Range + + def initialize: (identifier: String, range: Range) -> void + end + class LocalVariableItem attr_reader identifier: Symbol @@ -169,6 +177,7 @@ module Steep end type item = InstanceVariableItem + | KeywordArgumentItem | LocalVariableItem | ConstantItem | SimpleMethodNameItem @@ -216,6 +225,8 @@ module Steep def items_for_rbs: (position: Position, buffer: RBS::Buffer) -> Array[item] + def items_for_following_keyword_arguments: (String text, index: Integer, line: Integer, column: Integer, items: Array[item]) -> void + def method_items_for_receiver_type: (AST::Types::t, include_private: bool, prefix: String, position: Position, items: Array[item]) -> void def word_name?: (String name) -> bool @@ -226,6 +237,8 @@ module Steep def instance_variable_items_for_context: (TypeInference::Context context, position: Position, prefix: String, items: Array[item]) -> void + def keyword_argument_items_for_method: (node: Parser::AST::Node?, position: Position, prefix: String, items: Array[item]) -> void + def index_for: (String, line: Integer, column: Integer) -> Integer def disallowed_method?: (Symbol name) -> bool diff --git a/test/completion_provider_test.rb b/test/completion_provider_test.rb index 551a1de2b..d2ab74e58 100644 --- a/test/completion_provider_test.rb +++ b/test/completion_provider_test.rb @@ -547,4 +547,38 @@ def test_on_steep_type_application end end end + + def test_first_keyword_argument + with_checker < void +end +EOF + CompletionProvider.new(source_text: <<-EOR, path: Pathname("foo.rb"), subtyping: checker).tap do |provider| +TestClass.new.foo(a) + EOR + + provider.run(line: 1, column: 19).tap do |items| + assert_equal ["arg1:", "arg2:", "arg3:", "arg4:"], items.map(&:identifier) + end + end + end + end + + def test_following_keyword_argument + with_checker < void +end +EOF + CompletionProvider.new(source_text: <<-EOR, path: Pathname("foo.rb"), subtyping: checker).tap do |provider| +TestClass.new.foo(arg1: 1, a) + EOR + + provider.run(line: 1, column: 28).tap do |items| + assert_equal ["arg2:", "arg3:", "arg4:"], items.map(&:identifier) + end + end + end + end end