From 33f13272f6efc0ae20784bece93613bc86debd4a Mon Sep 17 00:00:00 2001 From: Bart de Water <118401830+bdewater-thatch@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:32:01 -0400 Subject: [PATCH] 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. --- lib/tapioca/dsl/compilers/url_helpers.rb | 214 ++++++- .../rbi/shims/generated_mounted_helpers.rbi | 3 + .../tapioca/dsl/compilers/url_helpers_spec.rb | 533 ++++++++++++++++++ 3 files changed, 743 insertions(+), 7 deletions(-) create mode 100644 sorbet/rbi/shims/generated_mounted_helpers.rbi diff --git a/lib/tapioca/dsl/compilers/url_helpers.rb b/lib/tapioca/dsl/compilers/url_helpers.rb index 84943e562..96af413d9 100644 --- a/lib/tapioca/dsl/compilers/url_helpers.rb +++ b/lib/tapioca/dsl/compilers/url_helpers.rb @@ -86,10 +86,13 @@ def decorate case constant when GeneratedPathHelpersModule.singleton_class, GeneratedUrlHelpersModule.singleton_class generate_module_for(root, constant) + when GeneratedMountedHelpers.singleton_class + generate_mounted_helpers_module(root) else - root.create_path(constant) do |mod| - create_mixins_for(mod, GeneratedUrlHelpersModule) - create_mixins_for(mod, GeneratedPathHelpersModule) + if engine_helper_module?(constant) + generate_module_for(root, constant) + else + generate_url_helper_includer end end end @@ -106,28 +109,55 @@ def gather_constants url_helpers_module = Rails.application.routes.named_routes.url_helpers_module path_helpers_module = Rails.application.routes.named_routes.path_helpers_module + mounted_helpers_module = Rails.application.routes.mounted_helpers Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module) Object.const_set(:GeneratedPathHelpersModule, path_helpers_module) + Object.const_set(:GeneratedMountedHelpers, Module.new) + + # Build engine registry: { mount_name => engine_class } + @engine_mount_names = T.let( + {}, + T.nilable(T::Hash[Symbol, T.class_of(::Rails::Engine)]), + ) + engine_helper_modules = setup_engine_helper_modules(mounted_helpers_module) constants = all_modules.select do |mod| next unless name_of(mod) # Fast-path to quickly disqualify most cases - next false unless url_helpers_module > mod || # rubocop:disable Style/InvertibleUnlessCondition + has_helpers = url_helpers_module > mod || path_helpers_module > mod || url_helpers_module > mod.singleton_class || path_helpers_module > mod.singleton_class + has_helpers ||= engine_helper_modules.any? do |engine_mod| + engine_mod > mod || engine_mod > mod.singleton_class + end + + next false unless has_helpers + includes_helper?(mod, url_helpers_module) || includes_helper?(mod, path_helpers_module) || includes_helper?(mod.singleton_class, url_helpers_module) || - includes_helper?(mod.singleton_class, path_helpers_module) + includes_helper?(mod.singleton_class, path_helpers_module) || + engine_helper_modules.any? { |engine_mod| includes_helper?(mod, engine_mod) || includes_helper?(mod.singleton_class, engine_mod) } end - constants.concat(NON_DISCOVERABLE_INCLUDERS).push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule) + constants + .concat(NON_DISCOVERABLE_INCLUDERS) + .push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule) + .push(GeneratedMountedHelpers) + .concat(engine_helper_modules) + end + + #: -> Hash[Symbol, singleton(::Rails::Engine)] + def engine_mount_names + @engine_mount_names || {} end + private + #: -> Array[Module[top]] def gather_non_discoverable_includers [].tap do |includers| @@ -141,10 +171,79 @@ def gather_non_discoverable_includers end.freeze end + #: (Module[top] mounted_helpers_module) -> Array[Module[top]] + def setup_engine_helper_modules(mounted_helpers_module) + routes_to_engine = {} + engine_helper_modules = [] #: Array[Module[top]] + + Rails.application.railties.grep(::Rails::Engine).each do |engine_instance| + engine_class = engine_instance.class + next if engine_class == Rails.application.class + + routes_to_engine[engine_instance.routes] = engine_class + + engine_path_helpers = engine_instance.routes.named_routes.path_helpers_module + engine_url_helpers = engine_instance.routes.named_routes.url_helpers_module + + # Skip engines with no routes + next if engine_path_helpers.instance_methods(false).empty? && + engine_url_helpers.instance_methods(false).empty? + + unless engine_class.const_defined?(:GeneratedPathHelpersModule, false) + engine_class.const_set(:GeneratedPathHelpersModule, engine_path_helpers) + end + + unless engine_class.const_defined?(:GeneratedUrlHelpersModule, false) + engine_class.const_set(:GeneratedUrlHelpersModule, engine_url_helpers) + end + + engine_helper_modules << engine_class.const_get(:GeneratedPathHelpersModule) + engine_helper_modules << engine_class.const_get(:GeneratedUrlHelpersModule) + end + + register_engine_mount_names(mounted_helpers_module, routes_to_engine) + + engine_helper_modules + end + + # Map mount names to engine classes by inspecting mounted_helpers methods. + # Rails' mounted_helpers methods call `_routes_context` on `self`, so we + # create a minimal context object that satisfies that interface. This lets + # us instantiate the RoutesProxy and read its @routes to match back to an + # engine's RouteSet. + #: (Module[top] mounted_helpers_module, Hash[untyped, untyped] routes_to_engine) -> void + def register_engine_mount_names(mounted_helpers_module, routes_to_engine) + context = Object.new + context.define_singleton_method(:_routes_context) { self } + + # Rails defines both public (blog) and private (_blog) mounted helpers. + # The public method delegates to the private one, which creates the + # RoutesProxy. We call the private method on our dummy context (since + # the public one would fail), but record the public name for RBI output. + mounted_helpers_module.instance_methods(false).each do |method_name| + next if method_name == :main_app + # Only process the public methods (non-underscore-prefixed) + next if method_name.start_with?("_") + + private_name = :"_#{method_name}" + next unless mounted_helpers_module.instance_methods(false).include?(private_name) + + begin + proxy = mounted_helpers_module.instance_method(private_name).bind_call(context) + engine_routes = proxy.instance_variable_get(:@routes) + engine_class = routes_to_engine[engine_routes] + T.must(@engine_mount_names)[method_name] = engine_class if engine_class + rescue + # If we can't resolve the mapping for this mount name, skip it + next + end + end + end + # Returns `true` if `mod` "directly" includes `helper`. # For classes, this method will return false if the `helper` is included only by a superclass #: (Module[top] mod, Module[top] helper) -> bool - private def includes_helper?(mod, helper) + def includes_helper?(mod, helper) ancestors = ancestors_of(mod) own_ancestors = if Class === mod && (superclass = superclass_of(mod)) @@ -178,6 +277,107 @@ def generate_module_for(root, constant) end end + #: (RBI::Tree root) -> void + def generate_mounted_helpers_module(root) + engine_mount_names = self.class.engine_mount_names + + root.create_module("GeneratedMountedHelpers") do |mod| + # main_app always returns a plain RoutesProxy + mod.create_method( + "main_app", + return_type: "ActionDispatch::Routing::RoutesProxy", + ) + + # One proxy method per mounted engine (only those with routes) + engine_mount_names.each do |mount_name, engine_class| + engine_name = name_of(engine_class) + next unless engine_name + next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false) + + proxy_class_name = "#{engine_name}::GeneratedRoutesProxy" + + mod.create_method( + mount_name.to_s, + return_type: proxy_class_name, + ) + end + end + + # Generate GeneratedRoutesProxy class for each engine (only those with routes) + engine_mount_names.each_value do |engine_class| + engine_name = name_of(engine_class) + next unless engine_name + next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false) + + proxy_class_name = "#{engine_name}::GeneratedRoutesProxy" + path_helpers_name = "#{engine_name}::GeneratedPathHelpersModule" + url_helpers_name = "#{engine_name}::GeneratedUrlHelpersModule" + + root.create_class(proxy_class_name, superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass| + klass.create_include(path_helpers_name) + klass.create_include(url_helpers_name) + end + end + end + + #: (Module[top] mod) -> bool + def engine_helper_module?(mod) + Rails.application.railties.grep(::Rails::Engine).any? do |engine_instance| + engine_class = engine_instance.class + next false if engine_class == Rails.application.class + next false unless engine_class.const_defined?(:GeneratedPathHelpersModule, false) + + mod == engine_class.const_get(:GeneratedPathHelpersModule) || + mod == engine_class.const_get(:GeneratedUrlHelpersModule) + end + end + + #: -> void + def generate_url_helper_includer + root.create_path(constant) do |mod| + create_mixins_for(mod, GeneratedUrlHelpersModule) + create_mixins_for(mod, GeneratedPathHelpersModule) + + # GeneratedMountedHelpers is Module.new (for naming), so check + # against the real mounted_helpers module for ancestor detection. + # Only controllers/framework classes actually have mounted_helpers + # in their ancestor chain; plain url_helpers includers do not. + mounted_helpers = Rails.application.routes.mounted_helpers + include_mounted = constant.ancestors.include?(mounted_helpers) || + NON_DISCOVERABLE_INCLUDERS.include?(constant) + extend_mounted = constant.singleton_class.ancestors.include?(mounted_helpers) + + mod.create_include("GeneratedMountedHelpers") if include_mounted + mod.create_extend("GeneratedMountedHelpers") if extend_mounted + + # Add engine-specific helper module mixins. + # Do NOT use create_mixins_for for engine helpers — its + # NON_DISCOVERABLE_INCLUDERS fallback would incorrectly add + # engine-specific modules to IntegrationTest and ActionView::Helpers. + Rails.application.railties.grep(::Rails::Engine).each do |engine_instance| + engine_class = engine_instance.class + next if engine_class == Rails.application.class + next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false) + + engine_path_mod = engine_class.const_get(:GeneratedPathHelpersModule) + engine_url_mod = engine_class.const_get(:GeneratedUrlHelpersModule) + + if constant.ancestors.include?(engine_url_mod) + mod.create_include(engine_url_mod.name) + end + if constant.singleton_class.ancestors.include?(engine_url_mod) + mod.create_extend(engine_url_mod.name) + end + if constant.ancestors.include?(engine_path_mod) + mod.create_include(engine_path_mod.name) + end + if constant.singleton_class.ancestors.include?(engine_path_mod) + mod.create_extend(engine_path_mod.name) + end + end + end + end + #: (RBI::Scope mod, Module[top] helper_module) -> void def create_mixins_for(mod, helper_module) include_helper = constant.ancestors.include?(helper_module) || NON_DISCOVERABLE_INCLUDERS.include?(constant) diff --git a/sorbet/rbi/shims/generated_mounted_helpers.rbi b/sorbet/rbi/shims/generated_mounted_helpers.rbi new file mode 100644 index 000000000..6765a454d --- /dev/null +++ b/sorbet/rbi/shims/generated_mounted_helpers.rbi @@ -0,0 +1,3 @@ +# typed: strict + +module GeneratedMountedHelpers; end diff --git a/spec/tapioca/dsl/compilers/url_helpers_spec.rb b/spec/tapioca/dsl/compilers/url_helpers_spec.rb index c060e4029..c0e14f609 100644 --- a/spec/tapioca/dsl/compilers/url_helpers_spec.rb +++ b/spec/tapioca/dsl/compilers/url_helpers_spec.rb @@ -25,6 +25,7 @@ class MyClass assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", ], @@ -44,6 +45,7 @@ class MyClass assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", "MyClass", @@ -64,6 +66,7 @@ class MyClass assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", "MyClass", @@ -86,6 +89,7 @@ class << self assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", "MyClass", @@ -109,6 +113,7 @@ class MyClass < SuperClass assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", "SuperClass", @@ -132,6 +137,7 @@ class MyClass < SuperClass assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", "SuperClass", @@ -156,6 +162,7 @@ class MyClass < SuperClass assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", "SuperClass", @@ -174,6 +181,7 @@ class Application < Rails::Application assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", ], @@ -204,12 +212,132 @@ class Foo < Bar assert_equal( [ + "GeneratedMountedHelpers", "GeneratedPathHelpersModule", "GeneratedUrlHelpersModule", ], gathered_constants, ) end + + it "gathers engine helper module constants when an engine is mounted" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + RUBY + + constants = gathered_constants + + assert_includes(constants, "Blog::Engine::GeneratedPathHelpersModule") + assert_includes(constants, "Blog::Engine::GeneratedUrlHelpersModule") + assert_includes(constants, "GeneratedMountedHelpers") + assert_includes(constants, "GeneratedPathHelpersModule") + assert_includes(constants, "GeneratedUrlHelpersModule") + end + + it "gathers constants for two mounted engines with distinct routes" do + add_ruby_file("engines.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + module Shop + class Engine < ::Rails::Engine + isolate_namespace Shop + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Shop::Engine.routes.draw do + resources :products + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + mount Shop::Engine => "/shop" + end + RUBY + + constants = gathered_constants + + assert_includes(constants, "Blog::Engine::GeneratedPathHelpersModule") + assert_includes(constants, "Blog::Engine::GeneratedUrlHelpersModule") + assert_includes(constants, "Shop::Engine::GeneratedPathHelpersModule") + assert_includes(constants, "Shop::Engine::GeneratedUrlHelpersModule") + assert_includes(constants, "GeneratedMountedHelpers") + end + + it "skips engines with no routes" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Empty + class Engine < ::Rails::Engine + isolate_namespace Empty + end + end + + Application.routes.draw do + mount Empty::Engine => "/empty" + end + RUBY + + constants = gathered_constants + + refute_includes(constants, "Empty::Engine::GeneratedPathHelpersModule") + refute_includes(constants, "Empty::Engine::GeneratedUrlHelpersModule") + assert_includes(constants, "GeneratedMountedHelpers") + end + + it "gathers constants that include engine-specific url_helpers" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + + class MyHelper + include Blog::Engine.routes.url_helpers + end + RUBY + + assert_includes(gathered_constants, "MyHelper") + end end describe "decorate" do @@ -310,6 +438,7 @@ class Application < Rails::Application class ActionDispatch::IntegrationTest include GeneratedUrlHelpersModule include GeneratedPathHelpersModule + include GeneratedMountedHelpers end RBI @@ -338,6 +467,7 @@ class Application < Rails::Application module ActionView::Helpers include GeneratedUrlHelpersModule include GeneratedPathHelpersModule + include GeneratedMountedHelpers end RBI @@ -474,6 +604,409 @@ class MyClass assert_equal(expected, rbi_for(:MyClass)) end + + it "generates RBI for engine GeneratedPathHelpersModule with helper methods" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + RUBY + + expected = <<~RBI + # typed: strong + + module Blog::Engine::GeneratedPathHelpersModule + include ::ActionDispatch::Routing::UrlFor + include ::ActionDispatch::Routing::PolymorphicRoutes + + sig { params(args: T.untyped).returns(String) } + def edit_post_path(*args); end + + sig { params(args: T.untyped).returns(String) } + def new_post_path(*args); end + + sig { params(args: T.untyped).returns(String) } + def post_path(*args); end + + sig { params(args: T.untyped).returns(String) } + def posts_path(*args); end + end + RBI + + assert_equal(expected, rbi_for("Blog::Engine::GeneratedPathHelpersModule")) + end + + it "generates RBI for engine GeneratedUrlHelpersModule with helper methods" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + RUBY + + expected = <<~RBI + # typed: strong + + module Blog::Engine::GeneratedUrlHelpersModule + include ::ActionDispatch::Routing::UrlFor + include ::ActionDispatch::Routing::PolymorphicRoutes + + sig { params(args: T.untyped).returns(String) } + def edit_post_url(*args); end + + sig { params(args: T.untyped).returns(String) } + def new_post_url(*args); end + + sig { params(args: T.untyped).returns(String) } + def post_url(*args); end + + sig { params(args: T.untyped).returns(String) } + def posts_url(*args); end + end + RBI + + assert_equal(expected, rbi_for("Blog::Engine::GeneratedUrlHelpersModule")) + end + + it "generates distinct RBI for two mounted engines" do + add_ruby_file("engines.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + module Shop + class Engine < ::Rails::Engine + isolate_namespace Shop + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Shop::Engine.routes.draw do + resources :products + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + mount Shop::Engine => "/shop" + end + RUBY + + blog_rbi = rbi_for("Blog::Engine::GeneratedPathHelpersModule") + shop_rbi = rbi_for("Shop::Engine::GeneratedPathHelpersModule") + + assert_includes(blog_rbi, "def posts_path") + refute_includes(blog_rbi, "def products_path") + + assert_includes(shop_rbi, "def products_path") + refute_includes(shop_rbi, "def posts_path") + end + + it "generates RBI for GeneratedMountedHelpers with main_app and engine proxy" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + RUBY + + rbi = rbi_for(:GeneratedMountedHelpers) + + assert_includes(rbi, "def main_app") + assert_includes(rbi, "returns(ActionDispatch::Routing::RoutesProxy)") + assert_includes(rbi, "def blog") + assert_includes(rbi, "returns(Blog::Engine::GeneratedRoutesProxy)") + assert_includes(rbi, "class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy") + assert_includes(rbi, "include Blog::Engine::GeneratedPathHelpersModule") + assert_includes(rbi, "include Blog::Engine::GeneratedUrlHelpersModule") + end + + it "generates RBI for two mounted engines with distinct RoutesProxy classes" do + add_ruby_file("engines.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + module Shop + class Engine < ::Rails::Engine + isolate_namespace Shop + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Shop::Engine.routes.draw do + resources :products + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + mount Shop::Engine => "/shop" + end + RUBY + + rbi = rbi_for(:GeneratedMountedHelpers) + + # Both engines have proxy methods + assert_includes(rbi, "def blog") + assert_includes(rbi, "returns(Blog::Engine::GeneratedRoutesProxy)") + assert_includes(rbi, "def shop") + assert_includes(rbi, "returns(Shop::Engine::GeneratedRoutesProxy)") + + # Both engines have distinct RoutesProxy classes + assert_includes(rbi, "class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy") + assert_includes(rbi, "include Blog::Engine::GeneratedPathHelpersModule") + assert_includes(rbi, "include Blog::Engine::GeneratedUrlHelpersModule") + + assert_includes(rbi, "class Shop::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy") + assert_includes(rbi, "include Shop::Engine::GeneratedPathHelpersModule") + assert_includes(rbi, "include Shop::Engine::GeneratedUrlHelpersModule") + end + + it "generates RBI for constant that includes url_helpers with GeneratedMountedHelpers" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + + class MyClass + include Rails.application.routes.url_helpers + end + RUBY + + rbi = rbi_for(:MyClass) + + assert_includes(rbi, "include GeneratedUrlHelpersModule") + assert_includes(rbi, "include GeneratedPathHelpersModule") + # Plain classes including url_helpers do NOT get GeneratedMountedHelpers + # (only controllers/framework classes have mounted_helpers in ancestors) + refute_includes(rbi, "GeneratedMountedHelpers") + end + + it "generates RBI for constant that includes engine-specific url_helpers" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + + class MyHelper + include Blog::Engine.routes.url_helpers + end + RUBY + + rbi = rbi_for(:MyHelper) + + assert_includes(rbi, "include Blog::Engine::GeneratedUrlHelpersModule") + assert_includes(rbi, "include Blog::Engine::GeneratedPathHelpersModule") + end + + it "generates RBI with extend for constant that extends url_helpers with engines mounted" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + + class MyClass + extend Rails.application.routes.url_helpers + end + RUBY + + rbi = rbi_for(:MyClass) + + assert_includes(rbi, "extend GeneratedUrlHelpersModule") + assert_includes(rbi, "extend GeneratedPathHelpersModule") + # Plain classes extending url_helpers do NOT get GeneratedMountedHelpers + refute_includes(rbi, "GeneratedMountedHelpers") + end + + describe "when Action Controller is loaded with mounted engines" do + #: -> void + def before_setup + require "rails" + require "action_controller" + end + + it "generates RBI for ActionDispatch::IntegrationTest with GeneratedMountedHelpers when engines are mounted" do + add_ruby_file("engine.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + Application.routes.draw do + mount Blog::Engine => "/blog" + end + RUBY + + rbi = rbi_for("ActionDispatch::IntegrationTest") + + assert_includes(rbi, "include GeneratedUrlHelpersModule") + assert_includes(rbi, "include GeneratedPathHelpersModule") + assert_includes(rbi, "include GeneratedMountedHelpers") + end + end + + it "generates identical RBI for includer class when no engines are mounted" do + add_ruby_file("routes.rb", <<~RUBY) + class Application < Rails::Application + routes.draw do + resource :index + end + end + + class MyClass + include Rails.application.routes.url_helpers + end + RUBY + + expected = <<~RBI + # typed: strong + + class MyClass + include GeneratedUrlHelpersModule + include GeneratedPathHelpersModule + end + RBI + + assert_equal(expected, rbi_for(:MyClass)) + end + + it "generates RBI for GeneratedMountedHelpers when one engine has routes and one has none" do + add_ruby_file("engines.rb", <<~RUBY) + class Application < Rails::Application + end + + module Blog + class Engine < ::Rails::Engine + isolate_namespace Blog + end + end + + module Empty + class Engine < ::Rails::Engine + isolate_namespace Empty + end + end + + Blog::Engine.routes.draw do + resources :posts + end + + # Empty::Engine has no routes drawn + + Application.routes.draw do + mount Blog::Engine => "/blog" + mount Empty::Engine => "/empty" + end + RUBY + + rbi = rbi_for(:GeneratedMountedHelpers) + + # Blog engine should be present + assert_includes(rbi, "def blog") + assert_includes(rbi, "Blog::Engine::GeneratedRoutesProxy") + + # Empty engine should NOT have a typed proxy (it was skipped in discovery) + refute_includes(rbi, "Empty::Engine::GeneratedRoutesProxy") + end end end end