lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.16.6 vs lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.17.0
- old
+ new
@@ -4,10 +4,11 @@
module RubyIndexer
class Index
extend T::Sig
class UnresolvableAliasError < StandardError; end
+ class NonExistingNamespaceError < 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 }
@@ -29,10 +30,13 @@
# }
@files_to_entries = T.let({}, T::Hash[String, T::Array[Entry]])
# Holds all require paths for every indexed item so that we can provide autocomplete for requires
@require_paths_tree = T.let(PrefixTree[IndexablePath].new, PrefixTree[IndexablePath])
+
+ # Holds the linearized ancestors list for every namespace
+ @ancestors = T.let({}, T::Hash[String, T::Array[String]])
end
sig { params(indexable: IndexablePath).void }
def delete(indexable)
# For each constant discovered in `path`, delete the associated entry from the index. If there are no entries
@@ -124,10 +128,20 @@
end
results.sort_by!(&:last)
results.flat_map(&:first)
end
+ sig { params(name: String, receiver_name: String).returns(T::Array[Entry]) }
+ def method_completion_candidates(name, receiver_name)
+ ancestors = linearized_ancestors_of(receiver_name)
+ candidates = prefix_search(name).flatten
+ candidates.select! do |entry|
+ 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
@@ -183,13 +197,15 @@
end
sig { params(indexable_path: IndexablePath, source: T.nilable(String)).void }
def index_single(indexable_path, source = nil)
content = source || File.read(indexable_path.full_path)
+ dispatcher = Prism::Dispatcher.new
+
result = Prism.parse(content)
- collector = Collector.new(self, result, indexable_path.full_path)
- collector.collect(result.value)
+ DeclarationListener.new(self, dispatcher, result, indexable_path.full_path)
+ dispatcher.dispatch(result.value)
require_path = indexable_path.require_path
@require_paths_tree.insert(require_path, indexable_path) if require_path
rescue Errno::EISDIR, Errno::ENOENT
# If `path` is a directory, just ignore it and continue indexing. If the file doesn't exist, then we also ignore
@@ -241,19 +257,178 @@
# 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])) }
def resolve_method(method_name, receiver_name)
method_entries = self[method_name]
- owner_entries = self[receiver_name]
- return unless owner_entries && method_entries
+ ancestors = linearized_ancestors_of(receiver_name.delete_prefix("::"))
+ return unless method_entries
- owner_name = T.must(owner_entries.first).name
- T.cast(
- method_entries.grep(Entry::Member).select do |entry|
- T.cast(entry, Entry::Member).owner&.name == owner_name
- end,
- T::Array[Entry::Member],
- )
+ ancestors.each do |ancestor|
+ found = method_entries.select do |entry|
+ next unless entry.is_a?(Entry::Member)
+
+ entry.owner&.name == ancestor
+ end
+
+ return T.cast(found, T::Array[Entry::Member]) if found.any?
+ end
+
+ nil
+ rescue NonExistingNamespaceError
+ nil
+ end
+
+ # Linearizes the ancestors for a given name, returning the order of namespaces in which Ruby will search for method
+ # or constant declarations.
+ #
+ # When we add an ancestor in Ruby, that namespace might have ancestors of its own. Therefore, we need to linearize
+ # everything recursively to ensure that we are placing ancestors in the right order. For example, if you include a
+ # module that prepends another module, then the prepend module appears before the included module.
+ #
+ # The order of ancestors is [linearized_prepends, self, linearized_includes, linearized_superclass]
+ sig { params(fully_qualified_name: String).returns(T::Array[String]) }
+ 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
+
+ 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
+ when Entry::Alias
+ self[entry.target]&.grep(Entry::Namespace)
+ end
+ end.flatten
+
+ raise NonExistingNamespaceError,
+ "None of the entries for #{fully_qualified_name} are modules or classes" if namespaces.empty?
+
+ mixin_operations = namespaces.flat_map(&:mixin_operations)
+ main_namespace_index = 0
+
+ # The original nesting where we discovered this namespace, so that we resolve the correct names of the
+ # included/prepended/extended modules and parent classes
+ nesting = T.must(namespaces.first).nesting
+
+ mixin_operations.each do |operation|
+ resolved_module = resolve(operation.module_name, nesting)
+ next unless resolved_module
+
+ module_fully_qualified_name = T.must(resolved_module.first).name
+
+ case operation
+ when Entry::Prepend
+ # When a module is prepended, Ruby checks if it hasn't been prepended already to prevent adding it in front of
+ # the actual namespace twice. However, it does not check if it has been included because you are allowed to
+ # prepend the same module after it has already been included
+ linearized_prepends = linearized_ancestors_of(module_fully_qualified_name)
+
+ # When there are duplicate prepended modules, we have to insert the new prepends after the existing ones. For
+ # example, if the current ancestors are `["A", "Foo"]` and we try to prepend `["A", "B"]`, then `"B"` has to
+ # be inserted after `"A`
+ uniq_prepends = linearized_prepends - T.must(ancestors[0...main_namespace_index])
+ insert_position = linearized_prepends.length - uniq_prepends.length
+
+ T.unsafe(ancestors).insert(
+ insert_position,
+ *(linearized_prepends - T.must(ancestors[0...main_namespace_index])),
+ )
+
+ main_namespace_index += linearized_prepends.length
+ when Entry::Include
+ # When including a module, Ruby will always prevent duplicate entries in case the module has already been
+ # prepended or included
+ linearized_includes = linearized_ancestors_of(module_fully_qualified_name)
+ T.unsafe(ancestors).insert(main_namespace_index + 1, *(linearized_includes - ancestors))
+ end
+ end
+
+ # Find the first class entry that has a parent class. Notice that if the developer makes a mistake and inherits
+ # from two diffent classes in different files, we simply ignore it
+ superclass = T.cast(namespaces.find { |n| n.is_a?(Entry::Class) && n.parent_class }, T.nilable(Entry::Class))
+
+ if superclass
+ # If the user makes a mistake and creates a class that inherits from itself, this method would throw a stack
+ # error. We need to ensure that this isn't the case
+ parent_class = T.must(superclass.parent_class)
+
+ 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
+ ancestors.concat(linearized_ancestors_of(parent_class_name))
+ end
+ end
+
+ ancestors
+ end
+
+ # Resolves an instance variable name for a given owner name. This method will linearize the ancestors of the owner
+ # and find inherited instance variables as well
+ sig { params(variable_name: String, owner_name: String).returns(T.nilable(T::Array[Entry::InstanceVariable])) }
+ def resolve_instance_variable(variable_name, owner_name)
+ entries = T.cast(self[variable_name], T.nilable(T::Array[Entry::InstanceVariable]))
+ return unless entries
+
+ ancestors = linearized_ancestors_of(owner_name)
+ return if ancestors.empty?
+
+ entries.select { |e| ancestors.include?(e.owner&.name) }
+ end
+
+ # Returns a list of possible candidates for completion of instance variables for a given owner name. The name must
+ # include the `@` prefix
+ 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
+ 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
+ sig { params(indexable: IndexablePath).void }
+ def handle_change(indexable)
+ original_entries = @files_to_entries[indexable.full_path]
+
+ delete(indexable)
+ index_single(indexable)
+
+ updated_entries = @files_to_entries[indexable.full_path]
+
+ return unless original_entries && updated_entries
+
+ # A change in one ancestor may impact several different others, which could be including that ancestor through
+ # indirect means like including a module that than includes the ancestor. Trying to figure out exactly which
+ # ancestors need to be deleted is too expensive. Therefore, if any of the namespace entries has a change to their
+ # ancestor hash, we clear all ancestors and start linearizing lazily again from scratch
+ original_map = T.cast(
+ original_entries.select { |e| e.is_a?(Entry::Namespace) },
+ T::Array[Entry::Namespace],
+ ).to_h { |e| [e.name, e.ancestor_hash] }
+
+ updated_map = T.cast(
+ updated_entries.select { |e| e.is_a?(Entry::Namespace) },
+ T::Array[Entry::Namespace],
+ ).to_h { |e| [e.name, e.ancestor_hash] }
+
+ @ancestors.clear if original_map.any? { |name, hash| updated_map[name] != hash }
end
private
# Attempts to resolve an UnresolvedAlias into a resolved Alias. If the unresolved alias is pointing to a constant