Skip to content
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

Add completion support for locals #2248

Merged
merged 1 commit into from
Jul 11, 2024
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
38 changes: 14 additions & 24 deletions lib/ruby_lsp/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,18 @@ def locate(node, char_position, node_types: [])
parent = T.let(nil, T.nilable(Prism::Node))
nesting_nodes = T.let(
[],
T::Array[T.any(Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode)],
T::Array[T.any(
Prism::ClassNode,
Prism::ModuleNode,
Prism::SingletonClassNode,
Prism::DefNode,
Prism::BlockNode,
Prism::LambdaNode,
Prism::ProgramNode,
)],
)

nesting_nodes << node if node.is_a?(Prism::ProgramNode)
call_node = T.let(nil, T.nilable(Prism::CallNode))

until queue.empty?
Expand Down Expand Up @@ -148,11 +158,8 @@ def locate(node, char_position, node_types: [])
# Keep track of the nesting where we found the target. This is used to determine the fully qualified name of the
# target when it is a constant
case candidate
when Prism::ClassNode, Prism::ModuleNode
nesting_nodes << candidate
when Prism::SingletonClassNode
nesting_nodes << candidate
when Prism::DefNode
when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode,
Prism::LambdaNode
nesting_nodes << candidate
end

Expand Down Expand Up @@ -193,24 +200,7 @@ def locate(node, char_position, node_types: [])
end
end

nesting = []
surrounding_method = T.let(nil, T.nilable(String))

nesting_nodes.each do |node|
case node
when Prism::ClassNode, Prism::ModuleNode
nesting << node.constant_path.slice
when Prism::SingletonClassNode
nesting << "<Class:#{nesting.last}>"
when Prism::DefNode
surrounding_method = node.name.to_s
next unless node.receiver.is_a?(Prism::SelfNode)

nesting << "<Class:#{nesting.last}>"
end
end

NodeContext.new(closest, parent, nesting, call_node, surrounding_method)
NodeContext.new(closest, parent, nesting_nodes, call_node)
end

sig { returns(T::Boolean) }
Expand Down
24 changes: 24 additions & 0 deletions lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ def complete_require_relative(node)

sig { params(node: Prism::CallNode, name: String).void }
def complete_methods(node, name)
add_local_completions(node, name)

type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

Expand Down Expand Up @@ -317,6 +319,28 @@ def complete_methods(node, name)
# We have not indexed this namespace, so we can't provide any completions
end

sig { params(node: Prism::CallNode, name: String).void }
def add_local_completions(node, name)
return if @global_state.has_type_checker

range = range_from_location(T.must(node.message_loc))

@node_context.locals_for_scope.each do |local|
local_name = local.to_s
next unless local_name.start_with?(name)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future feature idea: fuzzy matching?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think VS Code already does that. There are two ways to return completion items: a complete list or an incomplete one.

For the incomplete one, the server is responsible for re-computing all candidates one very keystroke (in this scenario, fuzzy matching on the server would indeed be very nice).

For the complete one, we return all matches for the first character and the client (VS Code) will handle the fuzzy matching on all candidates, which is what we currently do for performance reasons. Anything we can delegate to the client means less work for the server.


@response_builder << Interface::CompletionItem.new(
label: local_name,
filter_text: local_name,
text_edit: Interface::TextEdit.new(range: range, new_text: local_name),
kind: Constant::CompletionItemKind::VARIABLE,
data: {
skip_resolve: true,
},
)
end
end

sig { params(label: String, node: Prism::StringNode).returns(Interface::CompletionItem) }
def build_completion(label, node)
# We should use the content location as we only replace the content and not the delimiters of the string
Expand Down
70 changes: 65 additions & 5 deletions lib/ruby_lsp/node_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,82 @@ class NodeContext
params(
node: T.nilable(Prism::Node),
parent: T.nilable(Prism::Node),
nesting: T::Array[String],
nesting_nodes: T::Array[T.any(
Prism::ClassNode,
Prism::ModuleNode,
Prism::SingletonClassNode,
Prism::DefNode,
Prism::BlockNode,
Prism::LambdaNode,
Prism::ProgramNode,
)],
call_node: T.nilable(Prism::CallNode),
surrounding_method: T.nilable(String),
).void
end
def initialize(node, parent, nesting, call_node, surrounding_method)
def initialize(node, parent, nesting_nodes, call_node)
@node = node
@parent = parent
@nesting = nesting
@nesting_nodes = nesting_nodes
@call_node = call_node
@surrounding_method = surrounding_method

nesting, surrounding_method = handle_nesting_nodes(nesting_nodes)
@nesting = T.let(nesting, T::Array[String])
@surrounding_method = T.let(surrounding_method, T.nilable(String))
end

sig { returns(String) }
def fully_qualified_name
@fully_qualified_name ||= T.let(@nesting.join("::"), T.nilable(String))
end

sig { returns(T::Array[Symbol]) }
def locals_for_scope
locals = []

@nesting_nodes.each do |node|
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode) ||
node.is_a?(Prism::DefNode)
locals.clear
end

locals.concat(node.locals)
end

locals
end

private

sig do
params(nodes: T::Array[T.any(
Prism::ClassNode,
Prism::ModuleNode,
Prism::SingletonClassNode,
Prism::DefNode,
Prism::BlockNode,
Prism::LambdaNode,
Prism::ProgramNode,
)]).returns([T::Array[String], T.nilable(String)])
end
def handle_nesting_nodes(nodes)
nesting = []
surrounding_method = T.let(nil, T.nilable(String))

@nesting_nodes.each do |node|
case node
when Prism::ClassNode, Prism::ModuleNode
nesting << node.constant_path.slice
when Prism::SingletonClassNode
nesting << "<Class:#{nesting.last}>"
when Prism::DefNode
surrounding_method = node.name.to_s
next unless node.receiver.is_a?(Prism::SelfNode)

nesting << "<Class:#{nesting.last}>"
end
end

[nesting, surrounding_method]
end
end
end
2 changes: 2 additions & 0 deletions lib/ruby_lsp/requests/completion_resolve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def initialize(global_state, item)

sig { override.returns(T::Hash[Symbol, T.untyped]) }
def perform
return @item if @item.dig(:data, :skip_resolve)

# Based on the spec https://microsoft.github.io/language-server-protocol/specification#textDocument_completion,
# a completion resolve request must always return the original completion item without modifying ANY fields
# other than detail and documentation (NOT labelDetails). If we modify anything, the completion behaviour might
Expand Down
55 changes: 55 additions & 0 deletions test/requests/completion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,61 @@ def do_something
end
end

def test_completion_for_locals
source = +<<~RUBY
class Child
abc0 = 42

def do_something(abc1, abc2, abc3)
a

[].each do |abc4, abc5|
a
end
end

a
end

abc = 12
a
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: 4, character: 5 },
})

result = server.pop_response.response
assert_equal(["abc1", "abc2", "abc3"], result.map(&:label))

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

result = server.pop_response.response
assert_equal(["abc1", "abc2", "abc3", "abc4", "abc5"], result.map(&:label))

server.process_message(id: 1, method: "textDocument/completion", params: {
textDocument: { uri: uri },
position: { line: 11, character: 3 },
})

result = server.pop_response.response
assert_equal(["abc0"], result.map(&:label))

server.process_message(id: 1, method: "textDocument/completion", params: {
textDocument: { uri: uri },
position: { line: 15, character: 1 },
})

result = server.pop_response.response
assert_equal(["abc"], result.map(&:label))
end
end

private

def with_file_structure(server, &block)
Expand Down
Loading