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

Lazily resolve method aliases #2201

Merged
merged 2 commits into from
Jun 18, 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
43 changes: 43 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,9 @@ def initialize(name, file_path, location, comments, owner)
end
end

# An unresolved method alias is an alias entry for which we aren't sure what the right hand side points to yet. For
# example, if we have `alias a b`, we create an unresolved alias for `a` because we aren't sure immediate what `b`
# is referring to
class UnresolvedMethodAlias < Entry
extend T::Sig

Expand Down Expand Up @@ -426,5 +429,45 @@ def initialize(new_name, old_name, owner, file_path, location, comments) # ruboc
@owner = owner
end
end

# A method alias is a resolved alias entry that points to the exact method target it refers to
class MethodAlias < Entry
extend T::Sig

sig { returns(T.any(Member, MethodAlias)) }
attr_reader :target

sig { params(target: T.any(Member, MethodAlias), unresolved_alias: UnresolvedMethodAlias).void }
def initialize(target, unresolved_alias)
full_comments = ["Alias for #{target.name}\n"]
full_comments.concat(unresolved_alias.comments)
full_comments << "\n"
full_comments.concat(target.comments)

super(
unresolved_alias.new_name,
unresolved_alias.file_path,
unresolved_alias.location,
full_comments,
)

@target = target
end

sig { returns(T.nilable(Entry::Namespace)) }
def owner
@target.owner
end

sig { returns(T::Array[Parameter]) }
def parameters
@target.parameters
end

sig { returns(String) }
def decorated_parameters
@target.decorated_parameters
end
end
end
end
67 changes: 57 additions & 10 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,27 @@ def fuzzy_search(query)
results.flat_map(&:first)
end

sig { params(name: T.nilable(String), receiver_name: String).returns(T::Array[Entry]) }
sig do
params(
name: T.nilable(String),
receiver_name: String,
).returns(T::Array[T.any(Entry::Member, Entry::MethodAlias)])
end
def method_completion_candidates(name, receiver_name)
ancestors = linearized_ancestors_of(receiver_name)

candidates = name ? prefix_search(name).flatten : @entries.values.flatten
candidates.select! { |entry| entry.is_a?(Entry::Member) && ancestors.any?(entry.owner&.name) }
candidates
candidates.filter_map do |entry|
case entry
when Entry::Member, Entry::MethodAlias
entry if ancestors.any?(entry.owner&.name)
when Entry::UnresolvedMethodAlias
if ancestors.any?(entry.owner&.name)
resolved_alias = resolve_method_alias(entry, receiver_name)
resolved_alias if resolved_alias.is_a?(Entry::MethodAlias)
end
end
end
end

# Resolve a constant to its declaration based on its name and the nesting where the reference was found. Parameter
Expand Down Expand Up @@ -284,20 +298,32 @@ def follow_aliased_namespace(name, seen_names = [])

# Attempts to find methods for a resolved fully qualified receiver name.
# Returns `nil` if the method does not exist on that receiver
sig { params(method_name: String, receiver_name: String).returns(T.nilable(T::Array[Entry::Member])) }
sig do
params(
method_name: String,
receiver_name: String,
).returns(T.nilable(T::Array[T.any(Entry::Member, Entry::MethodAlias)]))
end
def resolve_method(method_name, receiver_name)
method_entries = self[method_name]
ancestors = linearized_ancestors_of(receiver_name.delete_prefix("::"))
return unless method_entries

ancestors = linearized_ancestors_of(receiver_name.delete_prefix("::"))
ancestors.each do |ancestor|
found = method_entries.select do |entry|
next unless entry.is_a?(Entry::Member)

entry.owner&.name == ancestor
found = method_entries.filter_map do |entry|
case entry
when Entry::Member, Entry::MethodAlias
entry if entry.owner&.name == ancestor
when Entry::UnresolvedMethodAlias
# Resolve aliases lazily as we find them
if entry.owner&.name == ancestor
resolved_alias = resolve_method_alias(entry, receiver_name)
resolved_alias if resolved_alias.is_a?(Entry::MethodAlias)
end
end
end

return T.cast(found, T::Array[Entry::Member]) if found.any?
return found if found.any?
end

nil
Expand Down Expand Up @@ -582,5 +608,26 @@ def direct_or_aliased_constant(full_name, seen_names)
def search_top_level(name, seen_names)
@entries[name]&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e }
end

# Attempt to resolve a given unresolved method alias. This method returns the resolved alias if we managed to
# identify the target or the same unresolved alias entry if we couldn't
sig do
params(
entry: Entry::UnresolvedMethodAlias,
receiver_name: String,
).returns(T.any(Entry::MethodAlias, Entry::UnresolvedMethodAlias))
end
def resolve_method_alias(entry, receiver_name)
return entry if entry.new_name == entry.old_name

target_method_entries = resolve_method(entry.old_name, receiver_name)
return entry unless target_method_entries

resolved_alias = Entry::MethodAlias.new(T.must(target_method_entries.first), entry)
original_entries = T.must(@entries[entry.new_name])
original_entries.delete(entry)
original_entries << resolved_alias
resolved_alias
end
end
end
121 changes: 121 additions & 0 deletions lib/ruby_indexer/test/index_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1351,5 +1351,126 @@ class << self
assert_equal(1, results.length)
assert_equal("Zwq", results.first.name)
end

def test_resolving_method_aliases
index(<<~RUBY)
class Foo
def bar(a, b, c)
end

alias double_alias bar
end

class Bar < Foo
def hello(b); end

alias baz bar
alias_method :qux, :hello
alias double double_alias
end
RUBY

# baz
methods = @index.resolve_method("baz", "Bar")
refute_nil(methods)

entry = T.must(methods.first)
assert_kind_of(Entry::MethodAlias, entry)
assert_equal("bar", entry.target.name)
assert_equal("Foo", T.must(entry.target.owner).name)

# qux
methods = @index.resolve_method("qux", "Bar")
refute_nil(methods)

entry = T.must(methods.first)
assert_kind_of(Entry::MethodAlias, entry)
assert_equal("hello", entry.target.name)
assert_equal("Bar", T.must(entry.target.owner).name)

# double
methods = @index.resolve_method("double", "Bar")
refute_nil(methods)

entry = T.must(methods.first)
assert_kind_of(Entry::MethodAlias, entry)

target = entry.target
assert_equal("double_alias", target.name)
assert_kind_of(Entry::MethodAlias, target)
assert_equal("Foo", T.must(target.owner).name)

final_target = target.target
assert_equal("bar", final_target.name)
assert_kind_of(Entry::Method, final_target)
assert_equal("Foo", T.must(final_target.owner).name)
end

def test_resolving_circular_method_aliases
index(<<~RUBY)
class Foo
alias bar bar
end
RUBY

# It's not possible to resolve an alias that points to itself
methods = @index.resolve_method("bar", "Foo")
assert_nil(methods)

entry = T.must(@index["bar"].first)
assert_kind_of(Entry::UnresolvedMethodAlias, entry)
end

def test_unresolable_method_aliases
index(<<~RUBY)
class Foo
alias bar baz
end
RUBY

# `baz` does not exist, so resolving `bar` is not possible
methods = @index.resolve_method("bar", "Foo")
assert_nil(methods)

entry = T.must(@index["bar"].first)
assert_kind_of(Entry::UnresolvedMethodAlias, entry)
end

def test_only_aliases_for_the_right_owner_are_resolved
index(<<~RUBY)
class Foo
attr_reader :name
alias_method :decorated_name, :name
end

class Bar
alias_method :decorated_name, :to_s
end
RUBY

methods = @index.resolve_method("decorated_name", "Foo")
refute_nil(methods)

entry = T.must(methods.first)
assert_kind_of(Entry::MethodAlias, entry)

target = entry.target
assert_equal("name", target.name)
assert_kind_of(Entry::Accessor, target)
assert_equal("Foo", T.must(target.owner).name)

other_decorated_name = T.must(@index["decorated_name"].find { |e| e.is_a?(Entry::UnresolvedMethodAlias) })
assert_kind_of(Entry::UnresolvedMethodAlias, other_decorated_name)
end

def test_completion_does_not_include_unresolved_aliases
index(<<~RUBY)
class Foo
alias_method :bar, :missing
end
RUBY

assert_empty(@index.method_completion_candidates("bar", "Foo"))
end
end
end
4 changes: 2 additions & 2 deletions lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ def complete_methods(node, name)
text_edit: Interface::TextEdit.new(range: range, new_text: entry_name),
kind: Constant::CompletionItemKind::METHOD,
data: {
owner_name: T.cast(entry, RubyIndexer::Entry::Member).owner&.name,
owner_name: entry.owner&.name,
},
)
end
Expand All @@ -309,7 +309,7 @@ def complete_methods(node, name)

sig do
params(
entry: RubyIndexer::Entry::Member,
entry: T.any(RubyIndexer::Entry::Member, RubyIndexer::Entry::MethodAlias),
node: Prism::CallNode,
).returns(Interface::CompletionItem)
end
Expand Down
25 changes: 25 additions & 0 deletions test/requests/completion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,31 @@ def self.yeah
end
end

def test_completion_for_aliased_methods
source = +<<~RUBY
class Parent
def bar(a); end
end

class Child < Parent
alias baz bar

def do_something
b
end
end
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: 8, character: 5 },
})
result = server.pop_response.response
assert_equal(["bar", "baz"], result.map(&:label))
end
end

private

def with_file_structure(server, &block)
Expand Down
27 changes: 27 additions & 0 deletions test/requests/definition_expectations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,33 @@ def baz
end
end

def test_definition_for_aliased_methods
source = <<~RUBY
class Parent
def bar; end
end

class Child < Parent
alias baz bar

def do_something
baz
end
end
RUBY

with_server(source) do |server, uri|
server.process_message(
id: 1,
method: "textDocument/definition",
params: { textDocument: { uri: uri }, position: { character: 4, line: 8 } },
)
response = server.pop_response.response

assert_equal(5, response[0].range.start.line)
end
end

private

def create_definition_addon
Expand Down
Loading
Loading