diff --git a/lib/tapioca/dsl/compilers/url_helpers.rb b/lib/tapioca/dsl/compilers/url_helpers.rb index 84943e562..06b18a1ac 100644 --- a/lib/tapioca/dsl/compilers/url_helpers.rb +++ b/lib/tapioca/dsl/compilers/url_helpers.rb @@ -7,7 +7,22 @@ module Tapioca module Dsl 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). + # [`Rails.application.routes.url_helpers`](https://api.rubyonrails.org/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 `articles`. 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. It is + # only generated for applications that mount an engine that defines its own routes. + # + # 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: # @@ -16,6 +31,8 @@ module Compilers # class Application < Rails::Application # routes.draw do # resource :index + # + # mount Blog::Engine, at: "/blog", as: "articles" # end # end # ~~~ @@ -42,6 +59,9 @@ module Compilers # include ActionDispatch::Routing::UrlFor # # sig { params(args: T.untyped).returns(String) } + # def articles_path(*args); end + # + # sig { params(args: T.untyped).returns(String) } # def edit_index_path(*args); end # # sig { params(args: T.untyped).returns(String) } @@ -60,6 +80,9 @@ module Compilers # include ActionDispatch::Routing::UrlFor # # sig { params(args: T.untyped).returns(String) } + # def articles_url(*args); end + # + # sig { params(args: T.untyped).returns(String) } # def edit_index_url(*args); end # # sig { params(args: T.untyped).returns(String) } @@ -78,6 +101,86 @@ module Compilers # include GeneratedUrlHelpersModule # end # ~~~ + # + # ~~~rb + # # blog/config/routes.rb + # Blog::Engine.routes.draw do + # resources :posts + # end + # ~~~ + # + # ~~~rbi + # # blog/engine/generated_path_helpers_module.rbi + # # typed: true + # module Blog::Engine::GeneratedPathHelpersModule + # include ActionDispatch::Routing::PolymorphicRoutes + # include ActionDispatch::Routing::UrlFor + # + # 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 + # # blog/engine/generated_url_helpers_module.rbi + # # typed: true + # module Blog::Engine::GeneratedUrlHelpersModule + # include ActionDispatch::Routing::PolymorphicRoutes + # include ActionDispatch::Routing::UrlFor + # + # 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 + # # generated_mounted_helpers.rbi + # # typed: true + # module GeneratedMountedHelpers + # sig { returns(Blog::Engine::GeneratedRoutesProxy) } + # def articles; end + # + # sig { returns(GeneratedRoutesProxy) } + # def main_app; end + # end + # ~~~ + # + # ~~~rbi + # # generated_routes_proxy.rbi + # # typed: true + # class GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy + # include GeneratedPathHelpersModule + # include GeneratedUrlHelpersModule + # end + # ~~~ + # + # ~~~rbi + # # blog/engine/generated_routes_proxy.rbi + # # typed: true + # class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy + # include Blog::Engine::GeneratedPathHelpersModule + # include Blog::Engine::GeneratedUrlHelpersModule + # end + # ~~~ + # #: [ConstantType = Module[top]] class UrlHelpers < Compiler # @override @@ -87,14 +190,25 @@ def decorate when GeneratedPathHelpersModule.singleton_class, GeneratedUrlHelpersModule.singleton_class generate_module_for(root, constant) else - root.create_path(constant) do |mod| - create_mixins_for(mod, GeneratedUrlHelpersModule) - create_mixins_for(mod, GeneratedPathHelpersModule) + # `GeneratedMountedHelpers` is only defined when an engine is mounted (see `gather_constants`). + if defined?(::GeneratedMountedHelpers) && GeneratedMountedHelpers.singleton_class === constant + generate_mounted_helpers_module(root) + elsif engine_helper_module?(constant) + generate_module_for(root, constant) + else + generate_url_helper_includer end end end + # Maps each engine's mount name to its class, e.g. `{ blog: Blog::Engine }`. + # Populated by `gather_constants` and read when generating the mounted helpers module. + @engine_mount_names = {} #: Hash[Symbol, singleton(::Rails::Engine)] + class << self + #: Hash[Symbol, singleton(::Rails::Engine)] + attr_reader :engine_mount_names + # @override #: -> Enumerable[Module[top]] def gather_constants @@ -110,24 +224,55 @@ def gather_constants Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module) Object.const_set(:GeneratedPathHelpersModule, path_helpers_module) + @engine_mount_names = mounted_engine_names + engine_helper_modules = register_engine_route_helpers + + # Only synthesize the mounted helpers module when at least one mounted engine + # contributes its own route helpers. A mount of a routeless engine (or an app with + # no mounts) would leave nothing but `main_app`, which we don't generate on its own. + # This predicate mirrors `proxied_engines` in `generate_mounted_helpers_module`. + mounts_engine_with_helpers = @engine_mount_names.values.any? do |engine_class| + name_of(engine_class) && engine_class.const_defined?(:GeneratedPathHelpersModule, false) + end + + if mounts_engine_with_helpers + Object.const_set(:GeneratedMountedHelpers, Module.new) + end + 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) + .concat(engine_helper_modules) + + constants.push(GeneratedMountedHelpers) if defined?(GeneratedMountedHelpers) + + constants end + private + #: -> Array[Module[top]] def gather_non_discoverable_includers [].tap do |includers| @@ -141,10 +286,59 @@ def gather_non_discoverable_includers end.freeze end + # Maps each mounted engine's mount name to its class (e.g. `{ articles: Blog::Engine }`). + # Reads the same route table that `bin/rails routes` inspects: a mounted engine appears + # as a route whose endpoint is the engine (`app.engine?`), with the mount name (or `as:` + # alias) as the route name. + #: -> Hash[Symbol, singleton(::Rails::Engine)] + def mounted_engine_names + Rails.application.routes.routes.each_with_object({}) do |route, mapping| + app = route.app + next unless app.respond_to?(:engine?) && app.engine? + + name = route.name + next unless name + + mapping[name.to_sym] = app.rack_app + end + end + + # Registers engine-scoped `GeneratedPathHelpersModule`/`GeneratedUrlHelpersModule` + # constants on each engine with routes, and returns those helper modules. + #: -> Array[Module[top]] + def register_engine_route_helpers + 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 + + 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 + + engine_helper_modules + 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 +372,116 @@ def generate_module_for(root, constant) end end + #: (RBI::Tree root) -> void + def generate_mounted_helpers_module(root) + # Mount name => engine name, for mounted engines that actually expose helper modules. + # Routeless engines (no `GeneratedPathHelpersModule`) and anonymous engines get no proxy. + # (If *every* mounted engine is routeless, `gather_constants` doesn't generate this module.) + proxied_engines = self.class.engine_mount_names.filter_map do |mount_name, engine_class| + engine_name = name_of(engine_class) + next unless engine_name + next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false) + + [mount_name, engine_name] + end + + root.create_module("GeneratedMountedHelpers") do |mod| + mod.create_method( + "main_app", + return_type: "GeneratedRoutesProxy", + ) + + proxied_engines.each do |mount_name, engine_name| + mod.create_method( + mount_name.to_s, + return_type: "#{engine_name}::GeneratedRoutesProxy", + ) + end + end + + # The application's own RoutesProxy subclass, returned by `main_app`. Mirrors the + # engine proxies below so that `main_app.post_path` & co. type-check. + root.create_class("GeneratedRoutesProxy", superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass| + klass.create_include("GeneratedPathHelpersModule") + klass.create_include("GeneratedUrlHelpersModule") + end + + # One RoutesProxy subclass per engine, `uniq` since an engine can be mounted under + # several names but needs only a single proxy class. + proxied_engines.map { |_mount_name, engine_name| engine_name }.uniq.each do |engine_name| + 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 only synthesized when an engine is mounted (see + # `gather_constants`). It is a fresh `Module.new` used purely for naming, so we + # 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. + if defined?(::GeneratedMountedHelpers) + 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 + end + + 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..15fc9b3b5 100644 --- a/manual/compiler_urlhelpers.md +++ b/manual/compiler_urlhelpers.md @@ -1,7 +1,22 @@ ## UrlHelpers `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). +[`Rails.application.routes.url_helpers`](https://api.rubyonrails.org/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 `articles`. 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. It is +only generated for applications that mount an engine that defines its own routes. + +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: @@ -10,6 +25,8 @@ For example, with the following setup: class Application < Rails::Application routes.draw do resource :index + + mount Blog::Engine, at: "/blog", as: "articles" end end ~~~ @@ -35,6 +52,9 @@ module GeneratedPathHelpersModule include ActionDispatch::Routing::PolymorphicRoutes include ActionDispatch::Routing::UrlFor + sig { params(args: T.untyped).returns(String) } + def articles_path(*args); end + sig { params(args: T.untyped).returns(String) } def edit_index_path(*args); end @@ -53,6 +73,9 @@ module GeneratedUrlHelpersModule include ActionDispatch::Routing::PolymorphicRoutes include ActionDispatch::Routing::UrlFor + sig { params(args: T.untyped).returns(String) } + def articles_url(*args); end + sig { params(args: T.untyped).returns(String) } def edit_index_url(*args); end @@ -72,3 +95,82 @@ class Post include GeneratedUrlHelpersModule end ~~~ + +~~~rb +# blog/config/routes.rb +Blog::Engine.routes.draw do + resources :posts +end +~~~ + +~~~rbi +# blog/engine/generated_path_helpers_module.rbi +# typed: true +module Blog::Engine::GeneratedPathHelpersModule + include ActionDispatch::Routing::PolymorphicRoutes + include ActionDispatch::Routing::UrlFor + + 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 +# blog/engine/generated_url_helpers_module.rbi +# typed: true +module Blog::Engine::GeneratedUrlHelpersModule + include ActionDispatch::Routing::PolymorphicRoutes + include ActionDispatch::Routing::UrlFor + + 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 +# generated_mounted_helpers.rbi +# typed: true +module GeneratedMountedHelpers + sig { returns(Blog::Engine::GeneratedRoutesProxy) } + def articles; end + + sig { returns(GeneratedRoutesProxy) } + def main_app; end +end +~~~ + +~~~rbi +# generated_routes_proxy.rbi +# typed: true +class GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy + include GeneratedPathHelpersModule + include GeneratedUrlHelpersModule +end +~~~ + +~~~rbi +# blog/engine/generated_routes_proxy.rbi +# typed: true +class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy + include Blog::Engine::GeneratedPathHelpersModule + include Blog::Engine::GeneratedUrlHelpersModule +end +~~~ 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..c787114bd 100644 --- a/spec/tapioca/dsl/compilers/url_helpers_spec.rb +++ b/spec/tapioca/dsl/compilers/url_helpers_spec.rb @@ -210,6 +210,127 @@ class Foo < Bar 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") + # A mount of a routeless engine contributes no helpers, so there's nothing to + # generate beyond `main_app` and GeneratedMountedHelpers is not synthesized. + refute_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 @@ -474,6 +595,439 @@ 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(GeneratedRoutesProxy)") + assert_includes(rbi, "class GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy") + assert_includes(rbi, "include GeneratedPathHelpersModule") + assert_includes(rbi, "include GeneratedUrlHelpersModule") + 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