diff --git a/lib/ruby_indexer/lib/ruby_indexer/index.rb b/lib/ruby_indexer/lib/ruby_indexer/index.rb index eedf2926a1..ddc60a5312 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/index.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/index.rb @@ -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 @@ -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("::") @@ -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 @@ -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| @@ -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 @@ -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 diff --git a/lib/ruby_indexer/test/index_test.rb b/lib/ruby_indexer/test/index_test.rb index ffcac974ef..bd8abd2e10 100644 --- a/lib/ruby_indexer/test/index_test.rb +++ b/lib/ruby_indexer/test/index_test.rb @@ -64,10 +64,7 @@ class Something refute_empty(entries) assert_equal("Foo::Bar", entries.first.name) - entries = @index.resolve("Foo::Bar", ["Foo", "Baz"]) - refute_empty(entries) - assert_equal("Foo::Bar", entries.first.name) - + assert_nil(@index.resolve("Foo::Bar", ["Foo", "Baz"])) assert_nil(@index.resolve("DoesNotExist", ["Foo"])) end @@ -181,6 +178,9 @@ class Bar; end def test_resolving_aliases_to_non_existing_constants_with_conflicting_names @index.index_single(IndexablePath.new("/fake", "/fake/path/foo.rb"), <<~RUBY) + class Float + end + module Foo class Float < self INFINITY = ::Float::INFINITY @@ -841,5 +841,212 @@ class Bar end end end + + def test_resolving_inherited_constants + index(<<~RUBY) + module Foo + CONST = 1 + end + + module Baz + CONST = 2 + end + + module Qux + include Foo + end + + module Namespace + CONST = 3 + + include Baz + + class Bar + include Qux + end + end + + CONST = 4 + RUBY + + entry = T.must(@index.resolve("CONST", ["Namespace", "Bar"])&.first) + assert_equal(14, entry.location.start_line) + end + + def test_resolving_inherited_alised_namespace + index(<<~RUBY) + module Bar + TARGET = 123 + end + + module Foo + CONST = Bar + end + + module Namespace + class Bar + include Foo + end + end + RUBY + + entry = T.must(@index.resolve("Foo::CONST::TARGET", [])&.first) + assert_equal(2, entry.location.start_line) + + entry = T.must(@index.resolve("Namespace::Bar::CONST::TARGET", [])&.first) + assert_equal(2, entry.location.start_line) + end + + def test_resolving_same_constant_from_different_scopes + index(<<~RUBY) + module Namespace + CONST = 123 + + class Parent + CONST = 321 + end + + class Child < Parent + end + end + RUBY + + entry = T.must(@index.resolve("CONST", ["Namespace", "Child"])&.first) + assert_equal(2, entry.location.start_line) + + entry = T.must(@index.resolve("Namespace::Child::CONST", [])&.first) + assert_equal(5, entry.location.start_line) + end + + def test_resolving_prepended_constants + index(<<~RUBY) + module Included + CONST = 123 + end + + module Prepended + CONST = 321 + end + + class Foo + include Included + prepend Prepended + end + + class Bar + CONST = 456 + include Included + prepend Prepended + end + RUBY + + entry = T.must(@index.resolve("CONST", ["Foo"])&.first) + assert_equal(6, entry.location.start_line) + + entry = T.must(@index.resolve("Foo::CONST", [])&.first) + assert_equal(6, entry.location.start_line) + + entry = T.must(@index.resolve("Bar::CONST", [])&.first) + assert_equal(15, entry.location.start_line) + end + + def test_resolving_constants_favors_ancestors_over_top_level + index(<<~RUBY) + module Value1 + CONST = 1 + end + + module Value2 + CONST = 2 + end + + CONST = 3 + module First + include Value1 + + module Second + include Value2 + end + end + RUBY + + entry = T.must(@index.resolve("CONST", ["First", "Second"])&.first) + assert_equal(6, entry.location.start_line) + end + + def test_resolving_circular_alias + index(<<~RUBY) + module Namespace + FOO = BAR + BAR = FOO + end + RUBY + + foo_entry = T.must(@index.resolve("FOO", ["Namespace"])&.first) + assert_equal(2, foo_entry.location.start_line) + assert_instance_of(Entry::Alias, foo_entry) + + bar_entry = T.must(@index.resolve("BAR", ["Namespace"])&.first) + assert_equal(3, bar_entry.location.start_line) + assert_instance_of(Entry::Alias, bar_entry) + end + + def test_resolving_circular_alias_three_levels + index(<<~RUBY) + module Namespace + FOO = BAR + BAR = BAZ + BAZ = FOO + end + RUBY + + foo_entry = T.must(@index.resolve("FOO", ["Namespace"])&.first) + assert_equal(2, foo_entry.location.start_line) + assert_instance_of(Entry::Alias, foo_entry) + + bar_entry = T.must(@index.resolve("BAR", ["Namespace"])&.first) + assert_equal(3, bar_entry.location.start_line) + assert_instance_of(Entry::Alias, bar_entry) + + baz_entry = T.must(@index.resolve("BAZ", ["Namespace"])&.first) + assert_equal(4, baz_entry.location.start_line) + assert_instance_of(Entry::Alias, baz_entry) + end + + def test_resolving_top_level_compact_reference + index(<<~RUBY) + class Foo::Bar + end + RUBY + + foo_entry = T.must(@index.resolve("Foo::Bar", [])&.first) + assert_equal(1, foo_entry.location.start_line) + assert_instance_of(Entry::Class, foo_entry) + end + + def test_resolving_references_with_redundant_namespaces + index(<<~RUBY) + module Bar + CONST = 1 + end + + module A + CONST = 2 + + module B + CONST = 3 + + class Foo + include Bar + end + + A::B::Foo::CONST + end + end + RUBY + + foo_entry = T.must(@index.resolve("A::B::Foo::CONST", ["A", "B"])&.first) + assert_equal(2, foo_entry.location.start_line) + end end end