diff --git a/lib/tapioca/dsl/compilers/url_helpers.rb b/lib/tapioca/dsl/compilers/url_helpers.rb index 84943e562..1cb23944f 100644 --- a/lib/tapioca/dsl/compilers/url_helpers.rb +++ b/lib/tapioca/dsl/compilers/url_helpers.rb @@ -9,6 +9,21 @@ module Compilers # `Tapioca::Dsl::Compilers::UrlHelpers` generates RBI files for classes that include or extend # [`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). # + # The compiler registers generated constants to represent the Rails route helper modules: + # + # 1. `GeneratedPathHelpersModule` holds the main application's path helpers, such as `post_path`. + # + # 2. `GeneratedUrlHelpersModule` holds the main application's URL helpers, such as `post_url`. + # + # 3. `GeneratedMountedHelpers` is a synthetic module for mounted application and engine helpers, such + # as `main_app` and `blog`. Rails exposes these helpers through an anonymous dynamic module, so the + # compiler creates a named RBI module that can be included or extended by classes that receive mounted + # helpers at runtime. + # + # For mounted engines, the compiler also registers engine-scoped `GeneratedPathHelpersModule` and + # `GeneratedUrlHelpersModule` constants. Mounted engine helper methods return a synthetic + # `GeneratedRoutesProxy` subclass that includes those engine-scoped helper modules. + # # For example, with the following setup: # # ~~~rb @@ -86,10 +101,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 +124,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 = register_engine_route_helpers(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 +186,79 @@ def gather_non_discoverable_includers end.freeze end + #: (Module[top] mounted_helpers_module) -> Array[Module[top]] + def register_engine_route_helpers(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_mounted_engine_helpers(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_mounted_engine_helpers(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 +292,127 @@ def generate_module_for(root, constant) end end + # Generates the mounted helper surface. For: + # + # ~~~rb + # mount Blog::Engine, at: "/blog", as: "articles" + # ~~~ + # + # it emits: + # + # ~~~rbi + # module GeneratedMountedHelpers + # sig { returns(Blog::Engine::GeneratedRoutesProxy) } + # def articles; end + # end + # + # class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy + # include Blog::Engine::GeneratedPathHelpersModule + # include Blog::Engine::GeneratedUrlHelpersModule + # 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 + + create_engine_helper_mixins(mod) + end + end + + #: (RBI::Scope mod) -> void + def create_engine_helper_mixins(mod) + 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) + + create_engine_helper_mixin(mod, engine_class.const_get(:GeneratedUrlHelpersModule)) + create_engine_helper_mixin(mod, engine_class.const_get(:GeneratedPathHelpersModule)) + end + end + + #: (RBI::Scope mod, Module[top] helper_module) -> void + def create_engine_helper_mixin(mod, helper_module) + # Engine helpers must be added only when actually present; the + # NON_DISCOVERABLE_INCLUDERS fallback is only valid for main app helpers. + if constant.ancestors.include?(helper_module) + mod.create_include(T.must(helper_module.name)) + end + + if constant.singleton_class.ancestors.include?(helper_module) + mod.create_extend(T.must(helper_module.name)) + 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/manual/compiler_urlhelpers.md b/manual/compiler_urlhelpers.md index 2a5de3081..1109ea1e1 100644 --- a/manual/compiler_urlhelpers.md +++ b/manual/compiler_urlhelpers.md @@ -3,6 +3,21 @@ `Tapioca::Dsl::Compilers::UrlHelpers` generates RBI files for classes that include or extend [`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). +The compiler registers generated constants to represent the Rails route helper modules: + +1. `GeneratedPathHelpersModule` holds the main application's path helpers, such as `post_path`. + +2. `GeneratedUrlHelpersModule` holds the main application's URL helpers, such as `post_url`. + +3. `GeneratedMountedHelpers` is a synthetic module for mounted application and engine helpers, such +as `main_app` and `blog`. Rails exposes these helpers through an anonymous dynamic module, so the +compiler creates a named RBI module that can be included or extended by classes that receive mounted +helpers at runtime. + +For mounted engines, the compiler also registers engine-scoped `GeneratedPathHelpersModule` and +`GeneratedUrlHelpersModule` constants. Mounted engine helper methods return a synthetic +`GeneratedRoutesProxy` subclass that includes those engine-scoped helper modules. + For example, with the following setup: ~~~rb 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..003a81e93 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,436 @@ 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 "uses the mount alias for GeneratedMountedHelpers proxy 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, at: "/blog", as: "articles" + end + RUBY + + rbi = rbi_for(:GeneratedMountedHelpers) + + assert_includes(rbi, "def articles") + assert_includes(rbi, "returns(Blog::Engine::GeneratedRoutesProxy)") + refute_includes(rbi, "def blog") + 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