Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for indexing enhancements through included hooks #2358

Merged
merged 3 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ class DeclarationListener
OBJECT_NESTING = T.let(["Object"].freeze, T::Array[String])
BASIC_OBJECT_NESTING = T.let(["BasicObject"].freeze, T::Array[String])

sig { returns(T::Array[String]) }
attr_reader :indexing_errors

sig do
params(index: Index, dispatcher: Prism::Dispatcher, parse_result: Prism::ParseResult, file_path: String).void
params(
index: Index,
dispatcher: Prism::Dispatcher,
parse_result: Prism::ParseResult,
file_path: String,
enhancements: T::Array[Enhancement],
).void
end
def initialize(index, dispatcher, parse_result, file_path)
def initialize(index, dispatcher, parse_result, file_path, enhancements: [])
@index = index
@file_path = file_path
@enhancements = enhancements
@visibility_stack = T.let([Entry::Visibility::PUBLIC], T::Array[Entry::Visibility])
@comments_by_line = T.let(
parse_result.comments.to_h do |c|
Expand All @@ -29,6 +39,7 @@ def initialize(index, dispatcher, parse_result, file_path)

# A stack of namespace entries that represent where we currently are. Used to properly assign methods to an owner
@owner_stack = T.let([], T::Array[Entry::Namespace])
@indexing_errors = T.let([], T::Array[String])

dispatcher.register(
self,
Expand Down Expand Up @@ -279,6 +290,12 @@ def on_call_node_enter(node)
when :private
@visibility_stack.push(Entry::Visibility::PRIVATE)
end

@enhancements.each do |enhancement|
enhancement.on_call_node(@index, @owner_stack.last, node, @file_path)
rescue StandardError => e
@indexing_errors << "Indexing error in #{@file_path} with '#{enhancement.class.name}' enhancement: #{e.message}"
end
end

sig { params(node: Prism::CallNode).void }
Expand Down
26 changes: 26 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/enhancement.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# typed: strict
# frozen_string_literal: true

module RubyIndexer
module Enhancement
extend T::Sig
extend T::Helpers

interface!
st0012 marked this conversation as resolved.
Show resolved Hide resolved

requires_ancestor { Object }

# The `on_extend` indexing enhancement is invoked whenever an extend is encountered in the code. It can be used to
# register for an included callback, similar to what `ActiveSupport::Concern` does in order to auto-extend the
# `ClassMethods` modules
sig do
abstract.params(
index: Index,
owner: T.nilable(Entry::Namespace),
node: Prism::CallNode,
file_path: String,
).void
end
def on_call_node(index, owner, node, file_path); end
end
end
71 changes: 70 additions & 1 deletion lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ def initialize

# Holds the linearized ancestors list for every namespace
@ancestors = T.let({}, T::Hash[String, T::Array[String]])

# List of classes that are enhancing the index
@enhancements = T.let([], T::Array[Enhancement])

# Map of module name to included hooks that have to be executed when we include the given module
@included_hooks = T.let(
{},
T::Hash[String, T::Array[T.proc.params(index: Index, base: Entry::Namespace).void]],
)
end

# Register an enhancement to the index. Enhancements must conform to the `Enhancement` interface
sig { params(enhancement: Enhancement).void }
def register_enhancement(enhancement)
@enhancements << enhancement
end

# Register an included `hook` that will be executed when `module_name` is included into any namespace
sig { params(module_name: String, hook: T.proc.params(index: Index, base: Entry::Namespace).void).void }
def register_included_hook(module_name, &hook)
(@included_hooks[module_name] ||= []) << hook
end

sig { params(indexable: IndexablePath).void }
Expand Down Expand Up @@ -296,11 +317,25 @@ def index_single(indexable_path, source = nil)
dispatcher = Prism::Dispatcher.new

result = Prism.parse(content)
DeclarationListener.new(self, dispatcher, result, indexable_path.full_path)
listener = DeclarationListener.new(
self,
dispatcher,
result,
indexable_path.full_path,
enhancements: @enhancements,
)
dispatcher.dispatch(result.value)

indexing_errors = listener.indexing_errors.uniq

require_path = indexable_path.require_path
@require_paths_tree.insert(require_path, indexable_path) if require_path

if indexing_errors.any?
indexing_errors.each do |error|
$stderr.puts error
end
end
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
# it
Expand Down Expand Up @@ -457,6 +492,12 @@ def linearized_ancestors_of(fully_qualified_name)
end
end

# We only need to run included hooks when linearizing singleton classes. Included hooks are typically used to add
# new singleton methods or to extend a module through an include. There's no need to support instance methods, the
# inclusion of another module or the prepending of another module, because those features are already a part of
# Ruby and can be used directly without any metaprogramming
run_included_hooks(attached_class_name, nesting) if singleton_levels > 0

linearize_mixins(ancestors, namespaces, nesting)
linearize_superclass(
ancestors,
Expand Down Expand Up @@ -570,6 +611,34 @@ def existing_or_new_singleton_class(name)

private

# Runs the registered included hooks
sig { params(fully_qualified_name: String, nesting: T::Array[String]).void }
def run_included_hooks(fully_qualified_name, nesting)
return if @included_hooks.empty?

namespaces = self[fully_qualified_name]&.grep(Entry::Namespace)
return unless namespaces

namespaces.each do |namespace|
namespace.mixin_operations.each do |operation|
next unless operation.is_a?(Entry::Include)

# First we resolve the include name, so that we know the actual module being referred to in the include
resolved_modules = resolve(operation.module_name, nesting)
next unless resolved_modules

module_name = T.must(resolved_modules.first).name

# Then we grab any hooks registered for that module
hooks = @included_hooks[module_name]
next unless hooks

# We invoke the hooks with the index and the namespace that included the module
hooks.each { |hook| hook.call(self, namespace) }
end
end
end

# Linearize mixins for an array of namespace entries. This method will mutate the `ancestors` array with the
# linearized ancestors of the mixins
sig do
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_indexer/ruby_indexer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

require "ruby_indexer/lib/ruby_indexer/indexable_path"
require "ruby_indexer/lib/ruby_indexer/declaration_listener"
require "ruby_indexer/lib/ruby_indexer/enhancement"
require "ruby_indexer/lib/ruby_indexer/index"
require "ruby_indexer/lib/ruby_indexer/entry"
require "ruby_indexer/lib/ruby_indexer/configuration"
Expand Down
197 changes: 197 additions & 0 deletions lib/ruby_indexer/test/enhancements_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# typed: true
# frozen_string_literal: true

require_relative "test_case"

module RubyIndexer
class EnhancementTest < TestCase
def test_enhancing_indexing_included_hook
enhancement_class = Class.new do
include Enhancement

def on_call_node(index, owner, node, file_path)
return unless owner
return unless node.name == :extend

arguments = node.arguments&.arguments
return unless arguments

location = node.location

arguments.each do |node|
next unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)

module_name = node.full_name
next unless module_name == "ActiveSupport::Concern"

index.register_included_hook(owner.name) do |index, base|
class_methods_name = "#{owner.name}::ClassMethods"

if index.indexed?(class_methods_name)
singleton = index.existing_or_new_singleton_class(base.name)
singleton.mixin_operations << Entry::Include.new(class_methods_name)
end
end

index.add(Entry::Method.new(
"new_method",
file_path,
location,
location,
[],
[Entry::Signature.new([Entry::RequiredParameter.new(name: :a)])],
Entry::Visibility::PUBLIC,
owner,
))
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError,
Prism::ConstantPathNode::MissingNodesInConstantPathError
# Do nothing
end
end
end

@index.register_enhancement(enhancement_class.new)
index(<<~RUBY)
module ActiveSupport
module Concern
def self.extended(base)
base.class_eval("def new_method(a); end")
end
end
end

module ActiveRecord
module Associations
extend ActiveSupport::Concern

module ClassMethods
def belongs_to(something); end
end
end

class Base
include Associations
end
end

class User < ActiveRecord::Base
end
RUBY

assert_equal(
[
"User::<Class:User>",
"ActiveRecord::Base::<Class:Base>",
"ActiveRecord::Associations::ClassMethods",
"Object::<Class:Object>",
"BasicObject::<Class:BasicObject>",
"Class",
"Module",
"Object",
"Kernel",
"BasicObject",
],
@index.linearized_ancestors_of("User::<Class:User>"),
)

assert_entry("new_method", Entry::Method, "/fake/path/foo.rb:10-4:10-33")
end

def test_enhancing_indexing_configuration_dsl
enhancement_class = Class.new do
include Enhancement

def on_call_node(index, owner, node, file_path)
return unless owner

name = node.name
return unless name == :has_many

arguments = node.arguments&.arguments
return unless arguments

association_name = arguments.first
return unless association_name.is_a?(Prism::SymbolNode)

location = association_name.location

index.add(Entry::Method.new(
T.must(association_name.value),
file_path,
location,
location,
[],
[],
Entry::Visibility::PUBLIC,
owner,
))
end
end

@index.register_enhancement(enhancement_class.new)
index(<<~RUBY)
module ActiveSupport
module Concern
def self.extended(base)
base.class_eval("def new_method(a); end")
end
end
end

module ActiveRecord
module Associations
extend ActiveSupport::Concern

module ClassMethods
def belongs_to(something); end
end
end

class Base
include Associations
end
end

class User < ActiveRecord::Base
has_many :posts
end
RUBY

assert_entry("posts", Entry::Method, "/fake/path/foo.rb:23-11:23-17")
end

def test_error_handling_in_enhancement
enhancement_class = Class.new do
include Enhancement

def on_call_node(index, owner, node, file_path)
raise "Error"
end

class << self
def name
"TestEnhancement"
end
end
end

@index.register_enhancement(enhancement_class.new)

_stdout, stderr = capture_io do
index(<<~RUBY)
module ActiveSupport
module Concern
def self.extended(base)
base.class_eval("def new_method(a); end")
end
end
end
RUBY
end

assert_match(%r{Indexing error in /fake/path/foo\.rb with 'TestEnhancement' enhancement}, stderr)
# The module should still be indexed
assert_entry("ActiveSupport::Concern", Entry::Module, "/fake/path/foo.rb:1-2:5-5")
end
end
end
Loading