@@ -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 )
0 commit comments