lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.10.1 vs lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.11.0

- old
+ new

@@ -3,10 +3,12 @@ module RubyIndexer class Index extend T::Sig + class UnresolvableAliasError < StandardError; end + # The minimum Jaro-Winkler similarity score for an entry to be considered a match for a given fuzzy search query ENTRY_SIMILARITY_THRESHOLD = 0.7 sig { void } def initialize @@ -123,18 +125,34 @@ # 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) - (nesting.length + 1).downto(0).each do |i| - prefix = T.must(nesting[0...i]).join("::") - full_name = prefix.empty? ? name : "#{prefix}::#{name}" - entries = @entries[full_name] - return entries if entries + 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 } if results end + nesting.length.downto(0).each do |i| + namespace = T.must(nesting[0...i]).join("::") + full_name = namespace.empty? ? 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) : e } if entries + end + nil + rescue UnresolvableAliasError + nil end sig { params(indexable_paths: T::Array[IndexablePath]).void } def index_all(indexable_paths: RubyIndexer.configuration.indexables) indexable_paths.each { |path| index_single(path) } @@ -150,10 +168,72 @@ @require_paths_tree.insert(require_path, indexable_path) if require_path rescue Errno::EISDIR # If `path` is a directory, just ignore it and continue indexing end + # Follows aliases in a namespace. The algorithm keeps checking if the name is an alias and then recursively follows + # it. The idea is that we test the name in parts starting from the complete name to the first namespace. For + # `Foo::Bar::Baz`, we would test: + # 1. Is `Foo::Bar::Baz` an alias? Get the target and recursively follow its target + # 2. Is `Foo::Bar` an alias? Get the target and recursively follow its target + # 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) + parts = name.split("::") + real_parts = [] + + (parts.length - 1).downto(0).each do |i| + current_name = T.must(parts[0..i]).join("::") + entry = @entries[current_name]&.first + + case entry + when Entry::Alias + target = entry.target + return follow_aliased_namespace("#{target}::#{real_parts.join("::")}") + when Entry::UnresolvedAlias + resolved = resolve_alias(entry) + + 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("::")}") + else + real_parts.unshift(T.must(parts[i])) + end + end + + real_parts.join("::") + end + + 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) + 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.delete(entry) + original_entries << resolved_alias + + @entries_tree.insert(entry.name, original_entries) + + resolved_alias + end + class Entry extend T::Sig sig { returns(String) } attr_reader :name @@ -165,16 +245,20 @@ attr_reader :location sig { returns(T::Array[String]) } attr_reader :comments + sig { returns(Symbol) } + attr_accessor :visibility + sig { params(name: String, file_path: String, location: YARP::Location, comments: T::Array[String]).void } def initialize(name, file_path, location, comments) @name = name @file_path = file_path @location = location @comments = comments + @visibility = T.let(:public, Symbol) end sig { returns(String) } def file_name File.basename(@file_path) @@ -192,9 +276,61 @@ class Class < Namespace end class Constant < Entry + end + + # An UnresolvedAlias points to a constant alias with a right hand side that has not yet been resolved. For + # example, if we find + # + # ```ruby + # CONST = Foo + # ``` + # Before we have discovered `Foo`, there's no way to eagerly resolve this alias to the correct target constant. + # All aliases are inserted as UnresolvedAlias in the index first and then we lazily resolve them to the correct + # target in [rdoc-ref:Index#resolve]. If the right hand side contains a constant that doesn't exist, then it's not + # possible to resolve the alias and it will remain an UnresolvedAlias until the right hand side constant exists + class UnresolvedAlias < Entry + extend T::Sig + + sig { returns(String) } + attr_reader :target + + sig { returns(T::Array[String]) } + attr_reader :nesting + + sig do + params( + target: String, + nesting: T::Array[String], + name: String, + file_path: String, + location: YARP::Location, + comments: T::Array[String], + ).void + end + def initialize(target, nesting, name, file_path, location, comments) # rubocop:disable Metrics/ParameterLists + super(name, file_path, location, comments) + + @target = target + @nesting = nesting + end + end + + # Alias represents a resolved alias, which points to an existing constant target + class Alias < Entry + extend T::Sig + + sig { returns(String) } + attr_reader :target + + sig { params(target: String, unresolved_alias: UnresolvedAlias).void } + def initialize(target, unresolved_alias) + super(unresolved_alias.name, unresolved_alias.file_path, unresolved_alias.location, unresolved_alias.comments) + + @target = target + end end end end end