From d85f8439ec991ae80b752a3268a8d0feaeb5c247 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 | 320 +++++++++- manual/compiler_urlhelpers.md | 104 +++- .../rbi/shims/generated_mounted_helpers.rbi | 3 + .../tapioca/dsl/compilers/url_helpers_spec.rb | 554 ++++++++++++++++++ 4 files changed, 972 insertions(+), 9 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..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