lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.17.4 vs lib/ruby_indexer/lib/ruby_indexer/index.rb in ruby-lsp-0.17.5
- old
+ new
@@ -148,21 +148,46 @@
end
def method_completion_candidates(name, receiver_name)
ancestors = linearized_ancestors_of(receiver_name)
candidates = name ? prefix_search(name).flatten : @entries.values.flatten
- candidates.filter_map do |entry|
- case entry
- when Entry::Member, Entry::MethodAlias
- entry if ancestors.any?(entry.owner&.name)
- when Entry::UnresolvedMethodAlias
- if ancestors.any?(entry.owner&.name)
- resolved_alias = resolve_method_alias(entry, receiver_name)
- resolved_alias if resolved_alias.is_a?(Entry::MethodAlias)
- end
+ completion_items = candidates.each_with_object({}) do |entry, hash|
+ unless entry.is_a?(Entry::Member) || entry.is_a?(Entry::MethodAlias) ||
+ entry.is_a?(Entry::UnresolvedMethodAlias)
+ next
end
+
+ entry_name = entry.name
+ ancestor_index = ancestors.index(entry.owner&.name)
+ existing_entry, existing_entry_index = hash[entry_name]
+
+ # Conditions for matching a method completion candidate:
+ # 1. If an ancestor_index was found, it means that this method is owned by the receiver. The exact index is
+ # where in the ancestor chain the method was found. For example, if the ancestors are ["A", "B", "C"] and we
+ # found the method declared in `B`, then the ancestors index is 1
+ #
+ # 2. We already established that this method is owned by the receiver. Now, check if we already added a
+ # completion candidate for this method name. If not, then we just go and add it (the left hand side of the or)
+ #
+ # 3. If we had already found a method entry for the same name, then we need to check if the current entry that
+ # we are comparing appears first in the hierarchy or not. For example, imagine we have the method `open` defined
+ # in both `File` and its parent `IO`. If we first find the method `open` in `IO`, it will be inserted into the
+ # hash. Then, when we find the entry for `open` owned by `File`, we need to replace `IO.open` by `File.open`,
+ # since `File.open` appears first in the hierarchy chain and is therefore the correct method being invoked. The
+ # last part of the conditional checks if the current entry was found earlier in the hierarchy chain, in which
+ # case we must update the existing entry to avoid showing the wrong method declaration for overridden methods
+ next unless ancestor_index && (!existing_entry || ancestor_index < existing_entry_index)
+
+ if entry.is_a?(Entry::UnresolvedMethodAlias)
+ resolved_alias = resolve_method_alias(entry, receiver_name)
+ hash[entry_name] = [resolved_alias, ancestor_index] if resolved_alias.is_a?(Entry::MethodAlias)
+ else
+ hash[entry_name] = [entry, ancestor_index]
+ end
end
+
+ completion_items.values.map!(&:first)
end
# Resolve a constant to its declaration based on its name and the nesting where the reference was found. Parameter
# documentation:
#
@@ -173,11 +198,15 @@
sig do
params(
name: String,
nesting: T::Array[String],
seen_names: T::Array[String],
- ).returns(T.nilable(T::Array[Entry]))
+ ).returns(T.nilable(T::Array[T.any(
+ Entry::Namespace,
+ Entry::Alias,
+ Entry::UnresolvedAlias,
+ )]))
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?("::")
entries = direct_or_aliased_constant(name.delete_prefix("::"), seen_names)
@@ -202,11 +231,11 @@
# 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)
+ direct_or_aliased_constant(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
@@ -343,12 +372,29 @@
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
+ parts = fully_qualified_name.split("::")
+ singleton_levels = 0
+
+ parts.reverse_each do |part|
+ break unless part.include?("<Class:")
+
+ singleton_levels += 1
+ parts.pop
+ end
+
+ attached_class_name = parts.join("::")
+
# If we don't have an entry for `name`, raise
entries = self[fully_qualified_name]
+
+ if singleton_levels > 0 && !entries
+ entries = [existing_or_new_singleton_class(attached_class_name)]
+ end
+
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
@@ -367,67 +413,30 @@
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))
+ if nesting.any?
+ singleton_levels.times do
+ nesting << "<Class:#{T.must(nesting.last)}>"
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))
+ linearize_mixins(ancestors, namespaces, nesting)
+ linearize_superclass(
+ ancestors,
+ attached_class_name,
+ fully_qualified_name,
+ namespaces,
+ nesting,
+ singleton_levels,
+ )
- 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
@@ -482,12 +491,179 @@
).to_h { |e| [e.name, e.ancestor_hash] }
@ancestors.clear if original_map.any? { |name, hash| updated_map[name] != hash }
end
+ sig { returns(T::Boolean) }
+ def empty?
+ @entries.empty?
+ end
+
+ sig { returns(T::Array[String]) }
+ def names
+ @entries.keys
+ end
+
+ sig { params(name: String).returns(T::Boolean) }
+ def indexed?(name)
+ @entries.key?(name)
+ end
+
+ sig { returns(Integer) }
+ def length
+ @entries.count
+ end
+
+ sig { params(name: String).returns(Entry::SingletonClass) }
+ def existing_or_new_singleton_class(name)
+ *_namespace, unqualified_name = name.split("::")
+ full_singleton_name = "#{name}::<Class:#{unqualified_name}>"
+ singleton = T.cast(self[full_singleton_name]&.first, T.nilable(Entry::SingletonClass))
+
+ unless singleton
+ attached_ancestor = T.must(self[name]&.first)
+
+ singleton = Entry::SingletonClass.new(
+ [full_singleton_name],
+ attached_ancestor.file_path,
+ attached_ancestor.location,
+ attached_ancestor.name_location,
+ [],
+ nil,
+ )
+ add(singleton, skip_prefix_tree: true)
+ end
+
+ singleton
+ end
+
private
+ # Linearize mixins for an array of namespace entries. This method will mutate the `ancestors` array with the
+ # linearized ancestors of the mixins
+ sig do
+ params(
+ ancestors: T::Array[String],
+ namespace_entries: T::Array[Entry::Namespace],
+ nesting: T::Array[String],
+ ).void
+ end
+ def linearize_mixins(ancestors, namespace_entries, nesting)
+ mixin_operations = namespace_entries.flat_map(&:mixin_operations)
+ main_namespace_index = 0
+
+ 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
+ end
+
+ # Linearize the superclass of a given namespace (including modules with the implicit `Module` superclass). This
+ # method will mutate the `ancestors` array with the linearized ancestors of the superclass
+ sig do
+ params(
+ ancestors: T::Array[String],
+ attached_class_name: String,
+ fully_qualified_name: String,
+ namespace_entries: T::Array[Entry::Namespace],
+ nesting: T::Array[String],
+ singleton_levels: Integer,
+ ).void
+ end
+ def linearize_superclass( # rubocop:disable Metrics/ParameterLists
+ ancestors,
+ attached_class_name,
+ fully_qualified_name,
+ namespace_entries,
+ nesting,
+ singleton_levels
+ )
+ # 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(
+ if singleton_levels > 0
+ self[attached_class_name]&.find { |n| n.is_a?(Entry::Class) && n.parent_class }
+ else
+ namespace_entries.find { |n| n.is_a?(Entry::Class) && n.parent_class }
+ end,
+ 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
+
+ parent_name_parts = [parent_class_name]
+ singleton_levels.times do
+ parent_name_parts << "<Class:#{parent_name_parts.last}>"
+ end
+
+ ancestors.concat(linearized_ancestors_of(parent_name_parts.join("::")))
+ end
+
+ # When computing the linearization for a class's singleton class, it inherits from the linearized ancestors of
+ # the `Class` class
+ if parent_class_name&.start_with?("BasicObject") && singleton_levels > 0
+ class_class_name_parts = ["Class"]
+
+ (singleton_levels - 1).times do
+ class_class_name_parts << "<Class:#{class_class_name_parts.last}>"
+ end
+
+ ancestors.concat(linearized_ancestors_of(class_class_name_parts.join("::")))
+ end
+ elsif singleton_levels > 0
+ # When computing the linearization for a module's singleton class, it inherits from the linearized ancestors of
+ # the `Module` class
+ mod = T.cast(self[attached_class_name]&.find { |n| n.is_a?(Entry::Module) }, T.nilable(Entry::Module))
+
+ if mod
+ module_class_name_parts = ["Module"]
+
+ (singleton_levels - 1).times do
+ module_class_name_parts << "<Class:#{module_class_name_parts.last}>"
+ end
+
+ ancestors.concat(linearized_ancestors_of(module_class_name_parts.join("::")))
+ end
+ end
+ end
+
# 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 do
params(
entry: Entry::UnresolvedAlias,
@@ -519,11 +695,15 @@
sig do
params(
name: String,
nesting: T::Array[String],
seen_names: T::Array[String],
- ).returns(T.nilable(T::Array[Entry]))
+ ).returns(T.nilable(T::Array[T.any(
+ Entry::Namespace,
+ Entry::Alias,
+ Entry::UnresolvedAlias,
+ )]))
end
def lookup_enclosing_scopes(name, nesting, seen_names)
nesting.length.downto(1).each do |i|
namespace = T.must(nesting[0...i]).join("::")
@@ -544,11 +724,15 @@
sig do
params(
name: String,
nesting: T::Array[String],
seen_names: T::Array[String],
- ).returns(T.nilable(T::Array[Entry]))
+ ).returns(T.nilable(T::Array[T.any(
+ Entry::Namespace,
+ Entry::Alias,
+ Entry::UnresolvedAlias,
+ )]))
end
def lookup_ancestor_chain(name, nesting, seen_names)
*nesting_parts, constant_name = build_non_redundant_full_name(name, nesting).split("::")
return if nesting_parts.empty?
@@ -596,18 +780,32 @@
# 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])) }
+ sig do
+ params(
+ full_name: String,
+ seen_names: T::Array[String],
+ ).returns(
+ T.nilable(T::Array[T.any(
+ Entry::Namespace,
+ Entry::Alias,
+ Entry::UnresolvedAlias,
+ )]),
+ )
+ end
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 }
+ T.cast(
+ entries&.map { |e| e.is_a?(Entry::UnresolvedAlias) ? resolve_alias(e, seen_names) : e },
+ T.nilable(T::Array[T.any(
+ Entry::Namespace,
+ Entry::Alias,
+ Entry::UnresolvedAlias,
+ )]),
+ )
end
# Attempt to resolve a given unresolved method alias. This method returns the resolved alias if we managed to
# identify the target or the same unresolved alias entry if we couldn't
sig do