lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.17.2 vs lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.17.3

- old
+ new

@@ -138,39 +138,52 @@ entry.is_a?(RubyIndexer::Entry::Member) && ancestors.any?(entry.owner&.name) end 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. Parameter + # documentation: + # + # name: the name of the reference how it was found in the source code (qualified or not) + # nesting: the nesting structure where the reference was found (e.g.: ["Foo", "Bar"]) + # seen_names: this parameter should not be used by consumers of the api. It is used to avoid infinite recursion when + # resolving circular references + 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 } + entries = direct_or_aliased_constant(name.delete_prefix("::"), seen_names) + return entries if entries end - nesting.length.downto(0).each do |i| - namespace = T.must(nesting[0...i]).join("::") - full_name = namespace.empty? ? name : "#{namespace}::#{name}" + # Non qualified reference path + full_name = nesting.any? ? "#{nesting.join("::")}::#{name}" : 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) : e } if entries - end + # 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 = direct_or_aliased_constant(full_name, seen_names) + return entries if entries - nil + # 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(name, nesting, seen_names) + return entries if entries + + # Finally, as a fallback, Ruby will search for the constant in the top level namespace + search_top_level(name, seen_names) rescue UnresolvableAliasError nil end # Index all files for the given indexable paths, which defaults to what is configured. A block can be used to track @@ -220,12 +233,12 @@ # 3. Is `Foo` an alias? Get the target and recursively follow its target # # 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("::") real_parts = [] @@ -234,20 +247,20 @@ entry = @entries[current_name]&.first 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 end @@ -289,21 +302,21 @@ def linearized_ancestors_of(fully_qualified_name) # If we already computed the ancestors for this namespace, return it straight away cached_ancestors = @ancestors[fully_qualified_name] return cached_ancestors if cached_ancestors + # If we don't have an entry for `name`, raise + entries = self[fully_qualified_name] + raise NonExistingNamespaceError, "No entry found for #{fully_qualified_name}" unless entries + ancestors = [fully_qualified_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 - # If none of the entries for `name` are namespaces, raise namespaces = entries.filter_map do |entry| case entry when Entry::Namespace entry @@ -393,12 +406,12 @@ sig { params(name: String, owner_name: String).returns(T::Array[Entry::InstanceVariable]) } def instance_variable_completion_candidates(name, owner_name) entries = T.cast(prefix_search(name).flatten, T::Array[Entry::InstanceVariable]) ancestors = linearized_ancestors_of(owner_name) - variables = entries.uniq(&:name) - variables.select! { |e| ancestors.any?(e.owner&.name) } + variables = entries.select { |e| ancestors.any?(e.owner&.name) } + variables.uniq!(&:name) variables end # Synchronizes a change made to the given indexable path. This method will ensure that new declarations are indexed, # removed declarations removed and that the ancestor linearization cache is cleared if necessary @@ -432,24 +445,127 @@ private # 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) + 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 = resolve(entry.target, entry.nesting, seen_names) return entry unless target target_name = T.must(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("::") + + # 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 = direct_or_aliased_constant("#{namespace}::#{name}", seen_names) + return entries if entries + end + + nil + end + + sig do + params( + name: String, + nesting: T::Array[String], + seen_names: T::Array[String], + ).returns(T.nilable(T::Array[Entry])) + end + def lookup_ancestor_chain(name, nesting, seen_names) + *nesting_parts, constant_name = build_non_redundant_full_name(name, nesting).split("::") + return if T.must(nesting_parts).empty? + + namespace_entries = resolve(T.must(nesting_parts).join("::"), [], seen_names) + return unless namespace_entries + + ancestors = T.must(nesting_parts).empty? ? [] : linearized_ancestors_of(T.must(namespace_entries.first).name) + + ancestors.each do |ancestor_name| + entries = direct_or_aliased_constant("#{ancestor_name}::#{constant_name}", seen_names) + return entries if entries + end + + nil + rescue NonExistingNamespaceError + nil + end + + # Removes redudancy from a constant reference's full name. For example, if we find a reference to `A::B::Foo` inside + # of the ["A", "B"] nesting, then we should not concatenate the nesting with the name or else we'll end up with + # `A::B::A::B::Foo`. This method will remove any redundant parts from the final name based on the reference and the + # nesting + sig { params(name: String, nesting: T::Array[String]).returns(String) } + def build_non_redundant_full_name(name, nesting) + return name if nesting.empty? + + namespace = nesting.join("::") + + # If the name is not qualified, we can just concatenate the nesting and the name + return "#{namespace}::#{name}" unless name.include?("::") + + name_parts = name.split("::") + + # Find the first part of the name that is not in the nesting + index = name_parts.index { |part| !nesting.include?(part) } + + if index.nil? + # All parts of the nesting are redundant because they are already present in the name. We can return the name + # directly + name + elsif index == 0 + # No parts of the nesting are in the name, we can concatenate the namespace and the name + "#{namespace}::#{name}" + else + # The name includes some parts of the nesting. We need to remove the redundant parts + "#{namespace}::#{T.must(name_parts[index..-1]).join("::")}" + end + end + + sig { params(full_name: String, seen_names: T::Array[String]).returns(T.nilable(T::Array[Entry])) } + def direct_or_aliased_constant(full_name, seen_names) + entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name)] + entries&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e } + end + + sig { params(name: String, seen_names: T::Array[String]).returns(T.nilable(T::Array[Entry])) } + def search_top_level(name, seen_names) + @entries[name]&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e } end end end