-
Notifications
You must be signed in to change notification settings - Fork 159
Add Rails engine support to UrlHelpers compiler #2632
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
bdewater-thatch marked this conversation as resolved.
|
||
| Object.const_set(:GeneratedPathHelpersModule, path_helpers_module) | ||
| Object.const_set(:GeneratedMountedHelpers, Module.new) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make this conditional, so it doesn't exist on Rails applications with no mounted engines? |
||
|
|
||
| # Build engine registry: { mount_name => engine_class } | ||
| @engine_mount_names = T.let( | ||
| {}, | ||
| T.nilable(T::Hash[Symbol, T.class_of(::Rails::Engine)]), | ||
| ) | ||
|
Comment on lines
+133
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This ivar has a very strange lifetime It's set here in
Comment on lines
+133
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you might be able to make this class-level ivar declaration on the class level (not inside a method), and it won't have to be nilable. Then the |
||
| 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 || {} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When can this actually be nil? |
||
| 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. | ||
|
Comment on lines
+225
to
+228
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there really no public API for this? 🤔 |
||
| #: (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 | ||
|
bdewater-thatch marked this conversation as resolved.
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please tighten this rescue clause up? What are we expecting? |
||
| # 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) | ||
|
bdewater-thatch marked this conversation as resolved.
|
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # typed: strict | ||
|
|
||
| module GeneratedMountedHelpers; end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be good to define
bloghere too with something like "blog(frommount Blog::Engine at: "/blog")"