Skip to content

Commit 006de5e

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 90305ce commit 006de5e

4 files changed

Lines changed: 819 additions & 7 deletions

File tree

lib/tapioca/dsl/compilers/url_helpers.rb

Lines changed: 241 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ module Compilers
99
# `Tapioca::Dsl::Compilers::UrlHelpers` generates RBI files for classes that include or extend
1010
# [`Rails.application.routes.url_helpers`](https://api.rubyonrails.org/v5.1.7/classes/ActionDispatch/Routing/UrlFor.html#module-ActionDispatch::Routing::UrlFor-label-URL+generation+for+named+routes).
1111
#
12+
# The compiler registers generated constants to represent the Rails route helper modules:
13+
#
14+
# 1. `GeneratedPathHelpersModule` holds the main application's path helpers, such as `post_path`.
15+
#
16+
# 2. `GeneratedUrlHelpersModule` holds the main application's URL helpers, such as `post_url`.
17+
#
18+
# 3. `GeneratedMountedHelpers` is a synthetic module for mounted application and engine helpers, such
19+
# as `main_app` and `blog`. Rails exposes these helpers through an anonymous dynamic module, so the
20+
# compiler creates a named RBI module that can be included or extended by classes that receive mounted
21+
# helpers at runtime.
22+
#
23+
# For mounted engines, the compiler also registers engine-scoped `GeneratedPathHelpersModule` and
24+
# `GeneratedUrlHelpersModule` constants. Mounted engine helper methods return a synthetic
25+
# `GeneratedRoutesProxy` subclass that includes those engine-scoped helper modules.
26+
#
1227
# For example, with the following setup:
1328
#
1429
# ~~~rb
@@ -86,10 +101,13 @@ def decorate
86101
case constant
87102
when GeneratedPathHelpersModule.singleton_class, GeneratedUrlHelpersModule.singleton_class
88103
generate_module_for(root, constant)
104+
when GeneratedMountedHelpers.singleton_class
105+
generate_mounted_helpers_module(root)
89106
else
90-
root.create_path(constant) do |mod|
91-
create_mixins_for(mod, GeneratedUrlHelpersModule)
92-
create_mixins_for(mod, GeneratedPathHelpersModule)
107+
if engine_helper_module?(constant)
108+
generate_module_for(root, constant)
109+
else
110+
generate_url_helper_includer
93111
end
94112
end
95113
end
@@ -106,28 +124,55 @@ def gather_constants
106124

107125
url_helpers_module = Rails.application.routes.named_routes.url_helpers_module
108126
path_helpers_module = Rails.application.routes.named_routes.path_helpers_module
127+
mounted_helpers_module = Rails.application.routes.mounted_helpers
109128

110129
Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module)
111130
Object.const_set(:GeneratedPathHelpersModule, path_helpers_module)
131+
Object.const_set(:GeneratedMountedHelpers, Module.new)
132+
133+
# Build engine registry: { mount_name => engine_class }
134+
@engine_mount_names = T.let(
135+
{},
136+
T.nilable(T::Hash[Symbol, T.class_of(::Rails::Engine)]),
137+
)
138+
engine_helper_modules = register_engine_route_helpers(mounted_helpers_module)
112139

113140
constants = all_modules.select do |mod|
114141
next unless name_of(mod)
115142

116143
# Fast-path to quickly disqualify most cases
117-
next false unless url_helpers_module > mod || # rubocop:disable Style/InvertibleUnlessCondition
144+
has_helpers = url_helpers_module > mod ||
118145
path_helpers_module > mod ||
119146
url_helpers_module > mod.singleton_class ||
120147
path_helpers_module > mod.singleton_class
121148

149+
has_helpers ||= engine_helper_modules.any? do |engine_mod|
150+
engine_mod > mod || engine_mod > mod.singleton_class
151+
end
152+
153+
next false unless has_helpers
154+
122155
includes_helper?(mod, url_helpers_module) ||
123156
includes_helper?(mod, path_helpers_module) ||
124157
includes_helper?(mod.singleton_class, url_helpers_module) ||
125-
includes_helper?(mod.singleton_class, path_helpers_module)
158+
includes_helper?(mod.singleton_class, path_helpers_module) ||
159+
engine_helper_modules.any? { |engine_mod| includes_helper?(mod, engine_mod) || includes_helper?(mod.singleton_class, engine_mod) }
126160
end
127161

128-
constants.concat(NON_DISCOVERABLE_INCLUDERS).push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
162+
constants
163+
.concat(NON_DISCOVERABLE_INCLUDERS)
164+
.push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
165+
.push(GeneratedMountedHelpers)
166+
.concat(engine_helper_modules)
167+
end
168+
169+
#: -> Hash[Symbol, singleton(::Rails::Engine)]
170+
def engine_mount_names
171+
@engine_mount_names || {}
129172
end
130173

174+
private
175+
131176
#: -> Array[Module[top]]
132177
def gather_non_discoverable_includers
133178
[].tap do |includers|
@@ -141,10 +186,79 @@ def gather_non_discoverable_includers
141186
end.freeze
142187
end
143188

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

150264
own_ancestors = if Class === mod && (superclass = superclass_of(mod))
@@ -178,6 +292,126 @@ def generate_module_for(root, constant)
178292
end
179293
end
180294

295+
# Generates the mounted helper surface. For:
296+
#
297+
# ~~~rb
298+
# mount Blog::Engine, at: "/blog", as: "articles"
299+
# ~~~
300+
#
301+
# it emits:
302+
#
303+
# ~~~rbi
304+
# module GeneratedMountedHelpers
305+
# sig { returns(Blog::Engine::GeneratedRoutesProxy) }
306+
# def articles; end
307+
# end
308+
#
309+
# class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy
310+
# include Blog::Engine::GeneratedPathHelpersModule
311+
# include Blog::Engine::GeneratedUrlHelpersModule
312+
# end
313+
# ~~~
314+
#: (RBI::Tree root) -> void
315+
def generate_mounted_helpers_module(root)
316+
engine_mount_names = self.class.engine_mount_names
317+
318+
root.create_module("GeneratedMountedHelpers") do |mod|
319+
# main_app always returns a plain RoutesProxy
320+
mod.create_method(
321+
"main_app",
322+
return_type: "ActionDispatch::Routing::RoutesProxy",
323+
)
324+
325+
# One proxy method per mounted engine (only those with routes)
326+
engine_mount_names.each do |mount_name, engine_class|
327+
engine_name = name_of(engine_class)
328+
next unless engine_name
329+
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
330+
331+
proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"
332+
333+
mod.create_method(
334+
mount_name.to_s,
335+
return_type: proxy_class_name,
336+
)
337+
end
338+
end
339+
340+
# Generate GeneratedRoutesProxy class for each engine (only those with routes)
341+
engine_mount_names.each_value do |engine_class|
342+
engine_name = name_of(engine_class)
343+
next unless engine_name
344+
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
345+
346+
proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"
347+
path_helpers_name = "#{engine_name}::GeneratedPathHelpersModule"
348+
url_helpers_name = "#{engine_name}::GeneratedUrlHelpersModule"
349+
350+
root.create_class(proxy_class_name, superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass|
351+
klass.create_include(path_helpers_name)
352+
klass.create_include(url_helpers_name)
353+
end
354+
end
355+
end
356+
357+
#: (Module[top] mod) -> bool
358+
def engine_helper_module?(mod)
359+
Rails.application.railties.grep(::Rails::Engine).any? do |engine_instance|
360+
engine_class = engine_instance.class
361+
next false if engine_class == Rails.application.class
362+
next false unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
363+
364+
mod == engine_class.const_get(:GeneratedPathHelpersModule) ||
365+
mod == engine_class.const_get(:GeneratedUrlHelpersModule)
366+
end
367+
end
368+
369+
#: -> void
370+
def generate_url_helper_includer
371+
root.create_path(constant) do |mod|
372+
create_mixins_for(mod, GeneratedUrlHelpersModule)
373+
create_mixins_for(mod, GeneratedPathHelpersModule)
374+
375+
# GeneratedMountedHelpers is Module.new (for naming), so check
376+
# against the real mounted_helpers module for ancestor detection.
377+
# Only controllers/framework classes actually have mounted_helpers
378+
# in their ancestor chain; plain url_helpers includers do not.
379+
mounted_helpers = Rails.application.routes.mounted_helpers
380+
include_mounted = constant.ancestors.include?(mounted_helpers) ||
381+
NON_DISCOVERABLE_INCLUDERS.include?(constant)
382+
extend_mounted = constant.singleton_class.ancestors.include?(mounted_helpers)
383+
384+
mod.create_include("GeneratedMountedHelpers") if include_mounted
385+
mod.create_extend("GeneratedMountedHelpers") if extend_mounted
386+
387+
# Add engine-specific helper module mixins.
388+
# Do NOT use create_mixins_for for engine helpers — its
389+
# NON_DISCOVERABLE_INCLUDERS fallback would incorrectly add
390+
# engine-specific modules to IntegrationTest and ActionView::Helpers.
391+
Rails.application.railties.grep(::Rails::Engine).each do |engine_instance|
392+
engine_class = engine_instance.class
393+
next if engine_class == Rails.application.class
394+
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
395+
396+
engine_path_mod = engine_class.const_get(:GeneratedPathHelpersModule)
397+
engine_url_mod = engine_class.const_get(:GeneratedUrlHelpersModule)
398+
399+
if constant.ancestors.include?(engine_url_mod)
400+
mod.create_include(engine_url_mod.name)
401+
end
402+
if constant.singleton_class.ancestors.include?(engine_url_mod)
403+
mod.create_extend(engine_url_mod.name)
404+
end
405+
if constant.ancestors.include?(engine_path_mod)
406+
mod.create_include(engine_path_mod.name)
407+
end
408+
if constant.singleton_class.ancestors.include?(engine_path_mod)
409+
mod.create_extend(engine_path_mod.name)
410+
end
411+
end
412+
end
413+
end
414+
181415
#: (RBI::Scope mod, Module[top] helper_module) -> void
182416
def create_mixins_for(mod, helper_module)
183417
include_helper = constant.ancestors.include?(helper_module) || NON_DISCOVERABLE_INCLUDERS.include?(constant)

manual/compiler_urlhelpers.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@
33
`Tapioca::Dsl::Compilers::UrlHelpers` generates RBI files for classes that include or extend
44
[`Rails.application.routes.url_helpers`](https://api.rubyonrails.org/v5.1.7/classes/ActionDispatch/Routing/UrlFor.html#module-ActionDispatch::Routing::UrlFor-label-URL+generation+for+named+routes).
55

6+
The compiler registers generated constants to represent the Rails route helper modules:
7+
8+
1. `GeneratedPathHelpersModule` holds the main application's path helpers, such as `post_path`.
9+
10+
2. `GeneratedUrlHelpersModule` holds the main application's URL helpers, such as `post_url`.
11+
12+
3. `GeneratedMountedHelpers` is a synthetic module for mounted application and engine helpers, such
13+
as `main_app` and `blog`. Rails exposes these helpers through an anonymous dynamic module, so the
14+
compiler creates a named RBI module that can be included or extended by classes that receive mounted
15+
helpers at runtime.
16+
17+
For mounted engines, the compiler also registers engine-scoped `GeneratedPathHelpersModule` and
18+
`GeneratedUrlHelpersModule` constants. Mounted engine helper methods return a synthetic
19+
`GeneratedRoutesProxy` subclass that includes those engine-scoped helper modules.
20+
621
For example, with the following setup:
722

823
~~~rb
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)