Skip to content

Commit 5dab70a

Browse files
Add Rails engine support to UrlHelpers compiler
Discover Rails engines during URL helper generation and register their engine-scoped path and URL helper modules. Generate mounted helper RBIs for engine mount points, including typed routes proxy classes that include the engine helper modules. Add mounted helper mixins only for classes/modules that actually receive Rails' mounted helpers, and cover the new behavior with regression tests.
1 parent fc4e78b commit 5dab70a

3 files changed

Lines changed: 753 additions & 9 deletions

File tree

lib/tapioca/dsl/compilers/url_helpers.rb

Lines changed: 217 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,13 @@ def decorate
8686
case constant
8787
when GeneratedPathHelpersModule.singleton_class, GeneratedUrlHelpersModule.singleton_class
8888
generate_module_for(root, constant)
89+
when GeneratedMountedHelpers.singleton_class
90+
generate_mounted_helpers_module(root)
8991
else
90-
root.create_path(constant) do |mod|
91-
create_mixins_for(mod, GeneratedUrlHelpersModule)
92-
create_mixins_for(mod, GeneratedPathHelpersModule)
92+
if engine_helper_module?(constant)
93+
generate_module_for(root, constant)
94+
else
95+
generate_url_helper_includer
9396
end
9497
end
9598
end
@@ -106,28 +109,63 @@ def gather_constants
106109

107110
url_helpers_module = Rails.application.routes.named_routes.url_helpers_module
108111
path_helpers_module = Rails.application.routes.named_routes.path_helpers_module
112+
mounted_helpers_module = Rails.application.routes.mounted_helpers
109113

110-
Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module)
111-
Object.const_set(:GeneratedPathHelpersModule, path_helpers_module)
114+
unless Object.const_defined?(:GeneratedUrlHelpersModule)
115+
Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module)
116+
end
117+
118+
unless Object.const_defined?(:GeneratedPathHelpersModule)
119+
Object.const_set(:GeneratedPathHelpersModule, path_helpers_module)
120+
end
121+
122+
unless Object.const_defined?(:GeneratedMountedHelpers)
123+
Object.const_set(:GeneratedMountedHelpers, Module.new)
124+
end
125+
126+
# Build engine registry: { mount_name => engine_class }
127+
@engine_mount_names = T.let(
128+
{},
129+
T.nilable(T::Hash[Symbol, T.class_of(::Rails::Engine)]),
130+
)
131+
engine_helper_modules = setup_engine_helper_modules(mounted_helpers_module)
112132

113133
constants = all_modules.select do |mod|
114134
next unless name_of(mod)
115135

116136
# Fast-path to quickly disqualify most cases
117-
next false unless url_helpers_module > mod || # rubocop:disable Style/InvertibleUnlessCondition
137+
has_main_app_helpers = url_helpers_module > mod ||
118138
path_helpers_module > mod ||
119139
url_helpers_module > mod.singleton_class ||
120140
path_helpers_module > mod.singleton_class
121141

142+
has_engine_helpers = engine_helper_modules.any? do |engine_mod|
143+
engine_mod > mod || engine_mod > mod.singleton_class
144+
end
145+
146+
next false unless has_main_app_helpers || has_engine_helpers
147+
122148
includes_helper?(mod, url_helpers_module) ||
123149
includes_helper?(mod, path_helpers_module) ||
124150
includes_helper?(mod.singleton_class, url_helpers_module) ||
125-
includes_helper?(mod.singleton_class, path_helpers_module)
151+
includes_helper?(mod.singleton_class, path_helpers_module) ||
152+
engine_helper_modules.any? { |engine_mod| includes_helper?(mod, engine_mod) || includes_helper?(mod.singleton_class, engine_mod) }
126153
end
127154

128-
constants.concat(NON_DISCOVERABLE_INCLUDERS).push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
155+
constants
156+
.concat(NON_DISCOVERABLE_INCLUDERS)
157+
.push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
158+
.push(GeneratedMountedHelpers)
159+
.concat(engine_helper_modules)
160+
end
161+
162+
#: -> Hash[Symbol, singleton(::Rails::Engine)]
163+
def engine_mount_names
164+
@engine_mount_names || {}
129165
end
130166

167+
private
168+
131169
#: -> Array[Module[top]]
132170
def gather_non_discoverable_includers
133171
[].tap do |includers|
@@ -141,10 +179,79 @@ def gather_non_discoverable_includers
141179
end.freeze
142180
end
143181

182+
#: (Module[top] mounted_helpers_module) -> Array[Module[top]]
183+
def setup_engine_helper_modules(mounted_helpers_module)
184+
routes_to_engine = {}
185+
engine_helper_modules = [] #: Array[Module[top]]
186+
187+
Rails.application.railties.grep(::Rails::Engine).each do |engine_instance|
188+
engine_class = engine_instance.class
189+
next if engine_class == Rails.application.class
190+
191+
routes_to_engine[engine_instance.routes] = engine_class
192+
193+
engine_path_helpers = engine_instance.routes.named_routes.path_helpers_module
194+
engine_url_helpers = engine_instance.routes.named_routes.url_helpers_module
195+
196+
# Skip engines with no routes
197+
next if engine_path_helpers.instance_methods(false).empty? &&
198+
engine_url_helpers.instance_methods(false).empty?
199+
200+
unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
201+
engine_class.const_set(:GeneratedPathHelpersModule, engine_path_helpers)
202+
end
203+
204+
unless engine_class.const_defined?(:GeneratedUrlHelpersModule, false)
205+
engine_class.const_set(:GeneratedUrlHelpersModule, engine_url_helpers)
206+
end
207+
208+
engine_helper_modules << engine_class.const_get(:GeneratedPathHelpersModule)
209+
engine_helper_modules << engine_class.const_get(:GeneratedUrlHelpersModule)
210+
end
211+
212+
register_engine_mount_names(mounted_helpers_module, routes_to_engine)
213+
214+
engine_helper_modules
215+
end
216+
217+
# Map mount names to engine classes by inspecting mounted_helpers methods.
218+
# Rails' mounted_helpers methods call `_routes_context` on `self`, so we
219+
# create a minimal context object that satisfies that interface. This lets
220+
# us instantiate the RoutesProxy and read its @routes to match back to an
221+
# engine's RouteSet.
222+
#: (Module[top] mounted_helpers_module, Hash[untyped, untyped] routes_to_engine) -> void
223+
def register_engine_mount_names(mounted_helpers_module, routes_to_engine)
224+
context = Object.new
225+
context.define_singleton_method(:_routes_context) { self }
226+
227+
# Rails defines both public (blog) and private (_blog) mounted helpers.
228+
# The public method delegates to the private one, which creates the
229+
# RoutesProxy. We call the private method on our dummy context (since
230+
# the public one would fail), but record the public name for RBI output.
231+
mounted_helpers_module.instance_methods(false).each do |method_name|
232+
next if method_name == :main_app
233+
# Only process the public methods (non-underscore-prefixed)
234+
next if method_name.start_with?("_")
235+
236+
private_name = :"_#{method_name}"
237+
next unless mounted_helpers_module.instance_methods(false).include?(private_name)
238+
239+
begin
240+
proxy = mounted_helpers_module.instance_method(private_name).bind_call(context)
241+
engine_routes = proxy.instance_variable_get(:@routes)
242+
engine_class = routes_to_engine[engine_routes]
243+
T.must(@engine_mount_names)[method_name] = engine_class if engine_class
244+
rescue
245+
# If we can't resolve the mapping for this mount name, skip it
246+
next
247+
end
248+
end
249+
end
250+
144251
# Returns `true` if `mod` "directly" includes `helper`.
145252
# For classes, this method will return false if the `helper` is included only by a superclass
146253
#: (Module[top] mod, Module[top] helper) -> bool
147-
private def includes_helper?(mod, helper)
254+
def includes_helper?(mod, helper)
148255
ancestors = ancestors_of(mod)
149256

150257
own_ancestors = if Class === mod && (superclass = superclass_of(mod))
@@ -178,6 +285,107 @@ def generate_module_for(root, constant)
178285
end
179286
end
180287

288+
#: (RBI::Tree root) -> void
289+
def generate_mounted_helpers_module(root)
290+
engine_mount_names = self.class.engine_mount_names
291+
292+
root.create_module("GeneratedMountedHelpers") do |mod|
293+
# main_app always returns a plain RoutesProxy
294+
mod.create_method(
295+
"main_app",
296+
return_type: "ActionDispatch::Routing::RoutesProxy",
297+
)
298+
299+
# One proxy method per mounted engine (only those with routes)
300+
engine_mount_names.each do |mount_name, engine_class|
301+
engine_name = name_of(engine_class)
302+
next unless engine_name
303+
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
304+
305+
proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"
306+
307+
mod.create_method(
308+
mount_name.to_s,
309+
return_type: proxy_class_name,
310+
)
311+
end
312+
end
313+
314+
# Generate GeneratedRoutesProxy class for each engine (only those with routes)
315+
engine_mount_names.each_value do |engine_class|
316+
engine_name = name_of(engine_class)
317+
next unless engine_name
318+
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
319+
320+
proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"
321+
path_helpers_name = "#{engine_name}::GeneratedPathHelpersModule"
322+
url_helpers_name = "#{engine_name}::GeneratedUrlHelpersModule"
323+
324+
root.create_class(proxy_class_name, superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass|
325+
klass.create_include(path_helpers_name)
326+
klass.create_include(url_helpers_name)
327+
end
328+
end
329+
end
330+
331+
#: (Module[top] mod) -> bool
332+
def engine_helper_module?(mod)
333+
Rails.application.railties.grep(::Rails::Engine).any? do |engine_instance|
334+
engine_class = engine_instance.class
335+
next false if engine_class == Rails.application.class
336+
next false unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
337+
338+
mod == engine_class.const_get(:GeneratedPathHelpersModule) ||
339+
mod == engine_class.const_get(:GeneratedUrlHelpersModule)
340+
end
341+
end
342+
343+
#: -> void
344+
def generate_url_helper_includer
345+
root.create_path(constant) do |mod|
346+
create_mixins_for(mod, GeneratedUrlHelpersModule)
347+
create_mixins_for(mod, GeneratedPathHelpersModule)
348+
349+
# GeneratedMountedHelpers is Module.new (for naming), so check
350+
# against the real mounted_helpers module for ancestor detection.
351+
# Only controllers/framework classes actually have mounted_helpers
352+
# in their ancestor chain; plain url_helpers includers do not.
353+
mounted_helpers = Rails.application.routes.mounted_helpers
354+
include_mounted = constant.ancestors.include?(mounted_helpers) ||
355+
NON_DISCOVERABLE_INCLUDERS.include?(constant)
356+
extend_mounted = constant.singleton_class.ancestors.include?(mounted_helpers)
357+
358+
mod.create_include("GeneratedMountedHelpers") if include_mounted
359+
mod.create_extend("GeneratedMountedHelpers") if extend_mounted
360+
361+
# Add engine-specific helper module mixins.
362+
# Do NOT use create_mixins_for for engine helpers — its
363+
# NON_DISCOVERABLE_INCLUDERS fallback would incorrectly add
364+
# engine-specific modules to IntegrationTest and ActionView::Helpers.
365+
Rails.application.railties.grep(::Rails::Engine).each do |engine_instance|
366+
engine_class = engine_instance.class
367+
next if engine_class == Rails.application.class
368+
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
369+
370+
engine_path_mod = engine_class.const_get(:GeneratedPathHelpersModule)
371+
engine_url_mod = engine_class.const_get(:GeneratedUrlHelpersModule)
372+
373+
if constant.ancestors.include?(engine_url_mod)
374+
mod.create_include(engine_url_mod.name)
375+
end
376+
if constant.singleton_class.ancestors.include?(engine_url_mod)
377+
mod.create_extend(engine_url_mod.name)
378+
end
379+
if constant.ancestors.include?(engine_path_mod)
380+
mod.create_include(engine_path_mod.name)
381+
end
382+
if constant.singleton_class.ancestors.include?(engine_path_mod)
383+
mod.create_extend(engine_path_mod.name)
384+
end
385+
end
386+
end
387+
end
388+
181389
#: (RBI::Scope mod, Module[top] helper_module) -> void
182390
def create_mixins_for(mod, helper_module)
183391
include_helper = constant.ancestors.include?(helper_module) || NON_DISCOVERABLE_INCLUDERS.include?(constant)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# typed: strict
2+
3+
module GeneratedMountedHelpers; end

0 commit comments

Comments
 (0)