Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 242 additions & 7 deletions lib/tapioca/dsl/compilers/url_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment thread
bdewater-thatch marked this conversation as resolved.
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|
Expand All @@ -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
Comment thread
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
# 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))
Expand Down Expand Up @@ -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)
Comment thread
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)
Expand Down
15 changes: 15 additions & 0 deletions manual/compiler_urlhelpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions sorbet/rbi/shims/generated_mounted_helpers.rbi
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# typed: strict

module GeneratedMountedHelpers; end
Loading
Loading