Skip to content

Commit

Permalink
Lazily resolve method aliases (#2201)
Browse files Browse the repository at this point in the history
* Lazily resolve method aliases

* Ensure features work with aliases
  • Loading branch information
vinistock authored Jun 18, 2024
1 parent 6172892 commit 93a6ffb
Show file tree
Hide file tree
Showing 8 changed files with 334 additions and 12 deletions.
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

0 comments on commit 93a6ffb

Please sign in to comment.