Skip to content

Commit

Permalink
Use classes to represent includes, prepends and extends
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed May 30, 2024
1 parent 641ea6f commit 9566119
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 51 deletions.
15 changes: 10 additions & 5 deletions lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb
Original file line number Diff line number Diff line change
Expand Up @@ -481,16 +481,21 @@ def handle_module_operation(node, operation)
arguments = node.arguments&.arguments
return unless arguments

names = arguments.filter_map do |node|
if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
[operation, node.full_name]
arguments.each do |node|
next unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)

case operation
when :include
owner.mixin_operations << Entry::Include.new(node.full_name)
when :prepend
owner.mixin_operations << Entry::Prepend.new(node.full_name)
when :extend
owner.mixin_operations << Entry::Extend.new(node.full_name)
end
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError,
Prism::ConstantPathNode::MissingNodesInConstantPathError
# Do nothing
end

owner.modules.concat(names)
end

sig { returns(Entry::Visibility) }
Expand Down
36 changes: 30 additions & 6 deletions lib/ruby_indexer/lib/ruby_indexer/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ def file_name
File.basename(@file_path)
end

class ModuleOperation
extend T::Sig
extend T::Helpers

abstract!

sig { returns(String) }
attr_reader :module_name

sig { params(module_name: String).void }
def initialize(module_name)
@module_name = module_name
end
end

class Include < ModuleOperation; end
class Prepend < ModuleOperation; end
class Extend < ModuleOperation; end

class Namespace < Entry
extend T::Sig
extend T::Helpers
Expand All @@ -87,12 +106,17 @@ def initialize(nesting, file_path, location, comments)
super(@name, file_path, location, comments)
end

# Stores all prepend, include and extend operations in the exact order they were discovered in the source code.
# Maintaining the order is essential to linearize ancestors the right way when a module is both included and
# prepended
sig { returns(T::Array[[Symbol, String]]) }
def modules
@modules ||= T.let([], T.nilable(T::Array[[Symbol, String]]))
sig { returns(T::Array[String]) }
def mixin_operation_module_names
mixin_operations.map(&:module_name)
end

# Stores all explicit prepend, include and extend operations in the exact order they were discovered in the source
# code. Maintaining the order is essential to linearize ancestors the right way when a module is both included
# and prepended
sig { returns(T::Array[ModuleOperation]) }
def mixin_operations
@mixin_operations ||= T.let([], T.nilable(T::Array[ModuleOperation]))
end
end

Expand Down
66 changes: 35 additions & 31 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -267,71 +267,73 @@ def resolve_method(method_name, receiver_name)
# 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(name: String).returns(T::Array[String]) }
def linearized_ancestors_of(name)
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[name]
cached_ancestors = @ancestors[fully_qualified_name]
return cached_ancestors if cached_ancestors

ancestors = [name]
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[name] = ancestors
@ancestors[fully_qualified_name] = ancestors

# If we don't have an entry for `name`, raise
entries = self[name]
raise NonExistingNamespaceError, "No entry found for #{name}" unless entries
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, return an empty array
namespaces = T.cast(entries.select { |e| e.is_a?(Entry::Namespace) }, T::Array[Entry::Namespace])
raise NonExistingNamespaceError, "None of the entries for #{name} are modules or classes" if namespaces.empty?
# 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

modules = namespaces.flat_map(&:modules)
prepended_modules_count = 0
included_modules_count = 0
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

modules.each do |operation, module_name|
resolved_module = resolve(module_name, nesting)
mixin_operations.each do |operation|
resolved_module = resolve(operation.module_name, nesting)
next unless resolved_module

fully_qualified_name = T.must(resolved_module.first).name
module_fully_qualified_name = T.must(resolved_module.first).name

case operation
when :prepend
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(fully_qualified_name)
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...prepended_modules_count])
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...prepended_modules_count])),
*(linearized_prepends - T.must(ancestors[0...main_namespace_index])),
)

prepended_modules_count += linearized_prepends.length
when :include
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(fully_qualified_name)

T.unsafe(ancestors).insert(
ancestors.length - included_modules_count,
*(linearized_includes - ancestors),
)

included_modules_count += linearized_includes.length
linearized_includes = linearized_ancestors_of(module_fully_qualified_name)
T.unsafe(ancestors).insert(main_namespace_index + 1, *(linearized_includes - ancestors))
end
end

Expand All @@ -347,7 +349,9 @@ def linearized_ancestors_of(name)
resolved_parent_class = resolve(parent_class, nesting)
parent_class_name = resolved_parent_class&.first&.name

ancestors.concat(linearized_ancestors_of(parent_class_name)) if parent_class_name && name != parent_class_name
if parent_class_name && fully_qualified_name != parent_class_name
ancestors.concat(linearized_ancestors_of(parent_class_name))
end
end

ancestors
Expand Down
18 changes: 9 additions & 9 deletions lib/ruby_indexer/test/classes_and_modules_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -369,13 +369,13 @@ class ConstantPathReferences
RUBY

foo = T.must(@index["Foo"][0])
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.modules.flat_map(&:last))
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.mixin_operation_module_names)

qux = T.must(@index["Foo::Qux"][0])
assert_equal(["Corge", "Corge", "Baz"], qux.modules.flat_map(&:last))
assert_equal(["Corge", "Corge", "Baz"], qux.mixin_operation_module_names)

constant_path_references = T.must(@index["ConstantPathReferences"][0])
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.modules.flat_map(&:last))
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.mixin_operation_module_names)
end

def test_keeping_track_of_prepended_modules
Expand Down Expand Up @@ -415,13 +415,13 @@ class ConstantPathReferences
RUBY

foo = T.must(@index["Foo"][0])
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.modules.flat_map(&:last))
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.mixin_operation_module_names)

qux = T.must(@index["Foo::Qux"][0])
assert_equal(["Corge", "Corge", "Baz"], qux.modules.flat_map(&:last))
assert_equal(["Corge", "Corge", "Baz"], qux.mixin_operation_module_names)

constant_path_references = T.must(@index["ConstantPathReferences"][0])
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.modules.flat_map(&:last))
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.mixin_operation_module_names)
end

def test_keeping_track_of_extended_modules
Expand Down Expand Up @@ -461,13 +461,13 @@ class ConstantPathReferences
RUBY

foo = T.must(@index["Foo"][0])
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.modules.flat_map(&:last))
assert_equal(["A1", "A2", "A3", "A4", "A5", "A6"], foo.mixin_operation_module_names)

qux = T.must(@index["Foo::Qux"][0])
assert_equal(["Corge", "Corge", "Baz"], qux.modules.flat_map(&:last))
assert_equal(["Corge", "Corge", "Baz"], qux.mixin_operation_module_names)

constant_path_references = T.must(@index["ConstantPathReferences"][0])
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.modules.flat_map(&:last))
assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.mixin_operation_module_names)
end
end
end
15 changes: 15 additions & 0 deletions lib/ruby_indexer/test/index_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -669,5 +669,20 @@ module Bar
assert_equal(["Foo"], @index.linearized_ancestors_of("Foo"))
assert_equal(["Bar"], @index.linearized_ancestors_of("Bar"))
end

def test_linearizing_circular_aliased_dependency
index(<<~RUBY)
module A
end
ALIAS = A
module A
include ALIAS
end
RUBY

assert_equal(["A", "ALIAS"], @index.linearized_ancestors_of("A"))
end
end
end

0 comments on commit 9566119

Please sign in to comment.