Skip to content

Commit 33f1327

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 33f1327

3 files changed

Lines changed: 743 additions & 7 deletions

File tree

lib/tapioca/dsl/compilers/url_helpers.rb

Lines changed: 207 additions & 7 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,55 @@ 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

110114
Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module)
111115
Object.const_set(:GeneratedPathHelpersModule, path_helpers_module)
116+
Object.const_set(:GeneratedMountedHelpers, Module.new)
117+
118+
# Build engine registry: { mount_name => engine_class }
119+
@engine_mount_names = T.let(
120+
{},
121+
T.nilable(T::Hash[Symbol, T.class_of(::Rails::Engine)]),
122+
)
123+
engine_helper_modules = setup_engine_helper_modules(mounted_helpers_module)
112124

113125
constants = all_modules.select do |mod|
114126
next unless name_of(mod)
115127

116128
# Fast-path to quickly disqualify most cases
117-
next false unless url_helpers_module > mod || # rubocop:disable Style/InvertibleUnlessCondition
129+
has_helpers = url_helpers_module > mod ||
118130
path_helpers_module > mod ||
119131
url_helpers_module > mod.singleton_class ||
120132
path_helpers_module > mod.singleton_class
121133

134+
has_helpers ||= engine_helper_modules.any? do |engine_mod|
135+
engine_mod > mod || engine_mod > mod.singleton_class
136+
end
137+
138+
next false unless has_helpers
139+
122140
includes_helper?(mod, url_helpers_module) ||
123141
includes_helper?(mod, path_helpers_module) ||
124142
includes_helper?(mod.singleton_class, url_helpers_module) ||
125-
includes_helper?(mod.singleton_class, path_helpers_module)
143+
includes_helper?(mod.singleton_class, path_helpers_module) ||
144+
engine_helper_modules.any? { |engine_mod| includes_helper?(mod, engine_mod) || includes_helper?(mod.singleton_class, engine_mod) }
126145
end
127146

128-
constants.concat(NON_DISCOVERABLE_INCLUDERS).push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
147+
constants
148+
.concat(NON_DISCOVERABLE_INCLUDERS)
149+
.push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
150+
.push(GeneratedMountedHelpers)
151+
.concat(engine_helper_modules)
152+
end
153+
154+
#: -> Hash[Symbol, singleton(::Rails::Engine)]
155+
def engine_mount_names
156+
@engine_mount_names || {}
129157
end
130158

159+
private
160+
131161
#: -> Array[Module[top]]
132162
def gather_non_discoverable_includers
133163
[].tap do |includers|
@@ -141,10 +171,79 @@ def gather_non_discoverable_includers
141171
end.freeze
142172
end
143173

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

150249
own_ancestors = if Class === mod && (superclass = superclass_of(mod))
@@ -178,6 +277,107 @@ def generate_module_for(root, constant)
178277
end
179278
end
180279

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