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