Skip to content

Commit 8110deb

Browse files
vinistockst0012
authored andcommitted
Add indexing enhancements and included hooks
1 parent a9fd899 commit 8110deb

File tree

5 files changed

+255
-3
lines changed

5 files changed

+255
-3
lines changed

lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb

+11-2
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@ class DeclarationListener
99
BASIC_OBJECT_NESTING = T.let(["BasicObject"].freeze, T::Array[String])
1010

1111
sig do
12-
params(index: Index, dispatcher: Prism::Dispatcher, parse_result: Prism::ParseResult, file_path: String).void
12+
params(
13+
index: Index,
14+
dispatcher: Prism::Dispatcher,
15+
parse_result: Prism::ParseResult,
16+
file_path: String,
17+
enhancements: T::Array[Enhancement],
18+
).void
1319
end
14-
def initialize(index, dispatcher, parse_result, file_path)
20+
def initialize(index, dispatcher, parse_result, file_path, enhancements: [])
1521
@index = index
1622
@file_path = file_path
23+
@enhancements = enhancements
1724
@visibility_stack = T.let([Entry::Visibility::PUBLIC], T::Array[Entry::Visibility])
1825
@comments_by_line = T.let(
1926
parse_result.comments.to_h do |c|
@@ -279,6 +286,8 @@ def on_call_node_enter(node)
279286
when :private
280287
@visibility_stack.push(Entry::Visibility::PRIVATE)
281288
end
289+
290+
@enhancements.each { |aug| aug.on_call_node(@index, @owner_stack.last, node, @file_path) }
282291
end
283292

284293
sig { params(node: Prism::CallNode).void }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyIndexer
5+
module Enhancement
6+
extend T::Sig
7+
extend T::Helpers
8+
9+
interface!
10+
11+
# The `on_extend` indexing enhancement is invoked whenever an extend is encountered in the code. It can be used to
12+
# register for an included callback, similar to what `ActiveSupport::Concern` does in order to auto-extend the
13+
# `ClassMethods` modules
14+
sig do
15+
abstract.params(
16+
index: Index,
17+
owner: T.nilable(Entry::Namespace),
18+
node: Prism::CallNode,
19+
file_path: String,
20+
).void
21+
end
22+
def on_call_node(index, owner, node, file_path); end
23+
end
24+
end

lib/ruby_indexer/lib/ruby_indexer/index.rb

+56-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ def initialize
3535

3636
# Holds the linearized ancestors list for every namespace
3737
@ancestors = T.let({}, T::Hash[String, T::Array[String]])
38+
39+
# List of classes that are enhancing the index
40+
@enhancements = T.let([], T::Array[Enhancement])
41+
42+
# Map of module name to included hooks that have to be executed when we include the given module
43+
@included_hooks = T.let(
44+
{},
45+
T::Hash[String, T::Array[T.proc.params(index: Index, base: Entry::Namespace).void]],
46+
)
47+
end
48+
49+
# Register an enhancement to the index. Enhancements must conform to the `Enhancement` interface
50+
sig { params(enhancement: Enhancement).void }
51+
def register_enhancement(enhancement)
52+
@enhancements << enhancement
53+
end
54+
55+
# Register an included `hook` that will be executed when `module_name` is included into any namespace
56+
sig { params(module_name: String, hook: T.proc.params(index: Index, base: Entry::Namespace).void).void }
57+
def register_included_hook(module_name, &hook)
58+
(@included_hooks[module_name] ||= []) << hook
3859
end
3960

4061
sig { params(indexable: IndexablePath).void }
@@ -296,7 +317,7 @@ def index_single(indexable_path, source = nil)
296317
dispatcher = Prism::Dispatcher.new
297318

298319
result = Prism.parse(content)
299-
DeclarationListener.new(self, dispatcher, result, indexable_path.full_path)
320+
DeclarationListener.new(self, dispatcher, result, indexable_path.full_path, enhancements: @enhancements)
300321
dispatcher.dispatch(result.value)
301322

302323
require_path = indexable_path.require_path
@@ -457,6 +478,12 @@ def linearized_ancestors_of(fully_qualified_name)
457478
end
458479
end
459480

481+
# We only need to run included hooks when linearizing singleton classes. Included hooks are typically used to add
482+
# new singleton methods or to extend a module through an include. There's no need to support instance methods, the
483+
# inclusion of another module or the prepending of another module, because those features are already a part of
484+
# Ruby and can be used directly without any metaprogramming
485+
run_included_hooks(attached_class_name, nesting) if singleton_levels > 0
486+
460487
linearize_mixins(ancestors, namespaces, nesting)
461488
linearize_superclass(
462489
ancestors,
@@ -570,6 +597,34 @@ def existing_or_new_singleton_class(name)
570597

571598
private
572599

600+
# Runs the registered included hooks
601+
sig { params(fully_qualified_name: String, nesting: T::Array[String]).void }
602+
def run_included_hooks(fully_qualified_name, nesting)
603+
return if @included_hooks.empty?
604+
605+
namespaces = self[fully_qualified_name]&.grep(Entry::Namespace)
606+
return unless namespaces
607+
608+
namespaces.each do |namespace|
609+
namespace.mixin_operations.each do |operation|
610+
next unless operation.is_a?(Entry::Include)
611+
612+
# First we resolve the include name, so that we know the actual module being referred to in the include
613+
resolved_modules = resolve(operation.module_name, nesting)
614+
next unless resolved_modules
615+
616+
module_name = T.must(resolved_modules.first).name
617+
618+
# Then we grab any hooks registered for that module
619+
hooks = @included_hooks[module_name]
620+
next unless hooks
621+
622+
# We invoke the hooks with the index and the namespace that included the module
623+
hooks.each { |hook| hook.call(self, namespace) }
624+
end
625+
end
626+
end
627+
573628
# Linearize mixins for an array of namespace entries. This method will mutate the `ancestors` array with the
574629
# linearized ancestors of the mixins
575630
sig do

lib/ruby_indexer/ruby_indexer.rb

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
require "ruby_indexer/lib/ruby_indexer/indexable_path"
88
require "ruby_indexer/lib/ruby_indexer/declaration_listener"
9+
require "ruby_indexer/lib/ruby_indexer/enhancement"
910
require "ruby_indexer/lib/ruby_indexer/index"
1011
require "ruby_indexer/lib/ruby_indexer/entry"
1112
require "ruby_indexer/lib/ruby_indexer/configuration"
+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require_relative "test_case"
5+
6+
module RubyIndexer
7+
class EnhancementTest < TestCase
8+
def test_enhancing_indexing_included_hook
9+
enhancement_class = Class.new do
10+
include Enhancement
11+
12+
def on_call_node(index, owner, node, file_path)
13+
return unless owner
14+
return unless node.name == :extend
15+
16+
arguments = node.arguments&.arguments
17+
return unless arguments
18+
19+
location = node.location
20+
21+
arguments.each do |node|
22+
next unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
23+
24+
module_name = node.full_name
25+
next unless module_name == "ActiveSupport::Concern"
26+
27+
index.register_included_hook(owner.name) do |index, base|
28+
class_methods_name = "#{owner.name}::ClassMethods"
29+
30+
if index.indexed?(class_methods_name)
31+
singleton = index.existing_or_new_singleton_class(base.name)
32+
singleton.mixin_operations << Entry::Include.new(class_methods_name)
33+
end
34+
end
35+
36+
index.add(Entry::Method.new(
37+
"new_method",
38+
file_path,
39+
location,
40+
location,
41+
[],
42+
[Entry::Signature.new([Entry::RequiredParameter.new(name: :a)])],
43+
Entry::Visibility::PUBLIC,
44+
owner,
45+
))
46+
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError,
47+
Prism::ConstantPathNode::MissingNodesInConstantPathError
48+
# Do nothing
49+
end
50+
end
51+
end
52+
53+
@index.register_enhancement(enhancement_class.new)
54+
index(<<~RUBY)
55+
module ActiveSupport
56+
module Concern
57+
def self.extended(base)
58+
base.class_eval("def new_method(a); end")
59+
end
60+
end
61+
end
62+
63+
module ActiveRecord
64+
module Associations
65+
extend ActiveSupport::Concern
66+
67+
module ClassMethods
68+
def belongs_to(something); end
69+
end
70+
end
71+
72+
class Base
73+
include Associations
74+
end
75+
end
76+
77+
class User < ActiveRecord::Base
78+
end
79+
RUBY
80+
81+
assert_equal(
82+
[
83+
"User::<Class:User>",
84+
"ActiveRecord::Base::<Class:Base>",
85+
"ActiveRecord::Associations::ClassMethods",
86+
"Object::<Class:Object>",
87+
"BasicObject::<Class:BasicObject>",
88+
"Class",
89+
"Module",
90+
"Object",
91+
"Kernel",
92+
"BasicObject",
93+
],
94+
@index.linearized_ancestors_of("User::<Class:User>"),
95+
)
96+
97+
assert_entry("new_method", Entry::Method, "/fake/path/foo.rb:10-4:10-33")
98+
end
99+
100+
def test_enhancing_indexing_configuration_dsl
101+
enhancement_class = Class.new do
102+
include Enhancement
103+
104+
def on_call_node(index, owner, node, file_path)
105+
return unless owner
106+
107+
name = node.name
108+
return unless name == :has_many
109+
110+
arguments = node.arguments&.arguments
111+
return unless arguments
112+
113+
association_name = arguments.first
114+
return unless association_name.is_a?(Prism::SymbolNode)
115+
116+
location = association_name.location
117+
118+
index.add(Entry::Method.new(
119+
T.must(association_name.value),
120+
file_path,
121+
location,
122+
location,
123+
[],
124+
[],
125+
Entry::Visibility::PUBLIC,
126+
owner,
127+
))
128+
end
129+
end
130+
131+
@index.register_enhancement(enhancement_class.new)
132+
index(<<~RUBY)
133+
module ActiveSupport
134+
module Concern
135+
def self.extended(base)
136+
base.class_eval("def new_method(a); end")
137+
end
138+
end
139+
end
140+
141+
module ActiveRecord
142+
module Associations
143+
extend ActiveSupport::Concern
144+
145+
module ClassMethods
146+
def belongs_to(something); end
147+
end
148+
end
149+
150+
class Base
151+
include Associations
152+
end
153+
end
154+
155+
class User < ActiveRecord::Base
156+
has_many :posts
157+
end
158+
RUBY
159+
160+
assert_entry("posts", Entry::Method, "/fake/path/foo.rb:23-11:23-17")
161+
end
162+
end
163+
end

0 commit comments

Comments
 (0)