Skip to content

Commit

Permalink
Implement complete constant resolution algorithm
Browse files Browse the repository at this point in the history
Co-authored-by: Alexandre Terrasa <Morriar@users.noreply.github.com>
  • Loading branch information
vinistock and Morriar committed Jun 5, 2024
1 parent 852b629 commit e424bf4
Show file tree
Hide file tree
Showing 2 changed files with 354 additions and 47 deletions.
186 changes: 143 additions & 43 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,36 +140,59 @@ def method_completion_candidates(name, receiver_name)
candidates
end

# Try to find the entry based on the nesting from the most specific to the least specific. For example, if we have
# the nesting as ["Foo", "Bar"] and the name as "Baz", we will try to find it in this order:
# 1. Foo::Bar::Baz
# 2. Foo::Baz
# 3. Baz
sig { params(name: String, nesting: T::Array[String]).returns(T.nilable(T::Array[Entry])) }
def resolve(name, nesting)
# Resolve a constant to its declaration based on its name and the nesting where the reference was found
sig do
params(
name: String,
nesting: T::Array[String],
seen_names: T::Array[String],
).returns(T.nilable(T::Array[Entry]))
end
def resolve(name, nesting, seen_names = [])
# If we have a top level reference, then we just search for it straight away ignoring the nesting
if name.start_with?("::")
name = name.delete_prefix("::")
results = @entries[name] || @entries[follow_aliased_namespace(name)]
return results&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e) : e }
full_name = name.delete_prefix("::")
entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name, seen_names)]
return entries&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e }
end

nesting.length.downto(0).each do |i|
namespace = T.must(nesting[0...i]).join("::")
full_name = namespace.empty? ? name : "#{namespace}::#{name}"
# If the name is qualified (has any namespace parts), then Ruby will search for the constant only in the exact
# namespace where the reference was found across the entire ancestor chain
if name.include?("::")
# Concatenate to the full name only non-redundant parts of the nesting. For example, if we found a reference to
# `A::B::Foo` inside the `A::B` nesting, then we should only use `A::B::Foo` and not `A::B::A::B::Foo`
full_name = name.dup
parts = name.split("::")

# If we find an entry with `full_name` directly, then we can already return it, even if it contains aliases -
# because the user might be trying to jump to the alias definition.
#
# However, if we don't find it, then we need to search for possible aliases in the namespace. For example, in
# the LSP itself we alias `RubyLsp::Interface` to `LanguageServer::Protocol::Interface`, which means doing
# `RubyLsp::Interface::Location` is allowed. For these cases, we need some way to realize that the
# `RubyLsp::Interface` part is an alias, that has to be resolved
entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name)]
return entries.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e) : e } if entries
nesting.length.times do |i|
next if parts[i] == nesting[i]

full_name.prepend("#{nesting[i]}::")
end

return lookup_ancestor_chain(full_name, seen_names)
end

nil
rescue UnresolvableAliasError
full_name = nesting.any? ? "#{nesting.join("::")}::#{name}" : name
# When the name is not qualified with any namespaces, Ruby will take several steps to try to the resolve the
# constant. First, it will try to find the constant in the exact namespace where the reference was found
entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name, seen_names)]
return entries.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e } if entries

# If the constant is not found yet, then Ruby will try to find the constant in the enclosing lexical scopes,
# unwrapping each level one by one. Important note: the top level is not included because that's the fallback of
# the algorithm after every other possibility has been exhausted
entries = lookup_enclosing_scopes(name, nesting, seen_names)
return entries if entries

# If the constant does not exist in any enclosing scopes, then Ruby will search for it in the ancestors of the
# specific namespace where the reference was found
entries = lookup_ancestor_chain(full_name, seen_names)
return entries if entries

# Finally, as a fallback, Ruby will search for the constant in the top level namespace
@entries[name]
rescue UnresolvableAliasError, NonExistingNamespaceError
nil
end

Expand Down Expand Up @@ -222,8 +245,8 @@ def index_single(indexable_path, source = nil)
# If we find an alias, then we want to follow its target. In the same example, if `Foo::Bar` is an alias to
# `Something::Else`, then we first discover `Something::Else::Baz`. But `Something::Else::Baz` might contain other
# aliases, so we have to invoke `follow_aliased_namespace` again to check until we only return a real name
sig { params(name: String).returns(String) }
def follow_aliased_namespace(name)
sig { params(name: String, seen_names: T::Array[String]).returns(String) }
def follow_aliased_namespace(name, seen_names = [])
return name if @entries[name]

parts = name.split("::")
Expand All @@ -236,16 +259,16 @@ def follow_aliased_namespace(name)
case entry
when Entry::Alias
target = entry.target
return follow_aliased_namespace("#{target}::#{real_parts.join("::")}")
return follow_aliased_namespace("#{target}::#{real_parts.join("::")}", seen_names)
when Entry::UnresolvedAlias
resolved = resolve_alias(entry)
resolved = resolve_alias(entry, seen_names)

if resolved.is_a?(Entry::UnresolvedAlias)
raise UnresolvableAliasError, "The constant #{resolved.name} is an alias to a non existing constant"
end

target = resolved.target
return follow_aliased_namespace("#{target}::#{real_parts.join("::")}")
return follow_aliased_namespace("#{target}::#{real_parts.join("::")}", seen_names)
else
real_parts.unshift(T.must(parts[i]))
end
Expand Down Expand Up @@ -291,16 +314,17 @@ def linearized_ancestors_of(fully_qualified_name)
cached_ancestors = @ancestors[fully_qualified_name]
return cached_ancestors if cached_ancestors

ancestors = [fully_qualified_name]
# If we don't have an entry for `name`, raise
entries = resolve(fully_qualified_name, [])
raise NonExistingNamespaceError, "No entry found for #{fully_qualified_name}" unless entries

resolved_name = T.must(entries.first).name
ancestors = [resolved_name]

# Cache the linearized ancestors array eagerly. This is important because we might have circular dependencies and
# this will prevent us from falling into an infinite recursion loop. Because we mutate the ancestors array later,
# the cache will reflect the final result
@ancestors[fully_qualified_name] = ancestors

# If we don't have an entry for `name`, raise
entries = resolve(fully_qualified_name, [])
raise NonExistingNamespaceError, "No entry found for #{fully_qualified_name}" unless entries
@ancestors[resolved_name] = ancestors

# If none of the entries for `name` are namespaces, raise
namespaces = entries.filter_map do |entry|
Expand Down Expand Up @@ -367,7 +391,7 @@ def linearized_ancestors_of(fully_qualified_name)
resolved_parent_class = resolve(parent_class, nesting)
parent_class_name = resolved_parent_class&.first&.name

if parent_class_name && fully_qualified_name != parent_class_name
if parent_class_name && resolved_name != parent_class_name
ancestors.concat(linearized_ancestors_of(parent_class_name))
end
end
Expand Down Expand Up @@ -434,22 +458,98 @@ def handle_change(indexable)

# Attempts to resolve an UnresolvedAlias into a resolved Alias. If the unresolved alias is pointing to a constant
# that doesn't exist, then we return the same UnresolvedAlias
sig { params(entry: Entry::UnresolvedAlias).returns(T.any(Entry::Alias, Entry::UnresolvedAlias)) }
def resolve_alias(entry)
target = resolve(entry.target, entry.nesting)
return entry unless target
sig do
params(
entry: Entry::UnresolvedAlias,
seen_names: T::Array[String],
).returns(T.any(Entry::Alias, Entry::UnresolvedAlias))
end
def resolve_alias(entry, seen_names)
alias_name = entry.name
return entry if seen_names.include?(alias_name)

seen_names << alias_name

target = entry.target

# Because we resolve aliases lazily, if we have a qualified alias target, we need to try to resolve it for each
# level of nesting. The reason is because we could be aliasing to a constant that's outside the current nesting,
# which wouldn't find anything. For a complete example of a qualified alias target pointing to a different
# namespace, see ConstantTest#test_aliasing_namespaces
resolved_target = if target.include?("::")
nesting = entry.nesting
results = T.let(nil, T.nilable(T::Array[Entry]))

nesting.length.downto(0).each do |i|
current_nesting = T.must(nesting[0...i])
results = resolve(target, current_nesting, seen_names)
break if results
end

results
else
resolve(target, entry.nesting, seen_names)
end

return entry unless resolved_target

target_name = T.must(target.first).name
target_name = T.must(resolved_target.first).name
resolved_alias = Entry::Alias.new(target_name, entry)

# Replace the UnresolvedAlias by a resolved one so that we don't have to do this again later
original_entries = T.must(@entries[entry.name])
original_entries = T.must(@entries[alias_name])
original_entries.delete(entry)
original_entries << resolved_alias

@entries_tree.insert(entry.name, original_entries)
@entries_tree.insert(alias_name, original_entries)

resolved_alias
end

sig do
params(
name: String,
nesting: T::Array[String],
seen_names: T::Array[String],
).returns(T.nilable(T::Array[Entry]))
end
def lookup_enclosing_scopes(name, nesting, seen_names)
nesting.length.downto(1).each do |i|
namespace = T.must(nesting[0...i]).join("::")
full_name = "#{namespace}::#{name}"

# If we find an entry with `full_name` directly, then we can already return it, even if it contains aliases -
# because the user might be trying to jump to the alias definition.
#
# However, if we don't find it, then we need to search for possible aliases in the namespace. For example, in
# the LSP itself we alias `RubyLsp::Interface` to `LanguageServer::Protocol::Interface`, which means doing
# `RubyLsp::Interface::Location` is allowed. For these cases, we need some way to realize that the
# `RubyLsp::Interface` part is an alias, that has to be resolved
entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name)]
return entries.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e } if entries
end

nil
end

sig { params(name: String, seen_names: T::Array[String]).returns(T.nilable(T::Array[Entry])) }
def lookup_ancestor_chain(name, seen_names)
results = @entries[name] || @entries[follow_aliased_namespace(name, seen_names)]
return results.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e } if results

*nesting_parts, constant_name = name.split("::")

ancestors = T.must(nesting_parts).empty? ? [] : linearized_ancestors_of(T.must(nesting_parts).join("::"))
ancestors.each do |ancestor_name|
complete_name = "#{ancestor_name}::#{constant_name}"
ancestor_results = @entries[complete_name] || @entries[follow_aliased_namespace(complete_name, seen_names)]

if ancestor_results
return ancestor_results.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e }
end
end

nil
end
end
end
Loading

0 comments on commit e424bf4

Please sign in to comment.