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
214 changes: 207 additions & 7 deletions lib/tapioca/dsl/compilers/url_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,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 +109,55 @@ def gather_constants

url_helpers_module = Rails.application.routes.named_routes.url_helpers_module
path_helpers_module = Rails.application.routes.named_routes.path_helpers_module
mounted_helpers_module = Rails.application.routes.mounted_helpers

Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module)
Object.const_set(:GeneratedPathHelpersModule, path_helpers_module)
Object.const_set(:GeneratedMountedHelpers, Module.new)

# Build engine registry: { mount_name => engine_class }
@engine_mount_names = T.let(
{},
T.nilable(T::Hash[Symbol, T.class_of(::Rails::Engine)]),
)
engine_helper_modules = setup_engine_helper_modules(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 +171,79 @@ def gather_non_discoverable_includers
end.freeze
end

#: (Module[top] mounted_helpers_module) -> Array[Module[top]]
def setup_engine_helper_modules(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_engine_mount_names(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_engine_mount_names(mounted_helpers_module, routes_to_engine)
context = Object.new
context.define_singleton_method(:_routes_context) { self }

# Rails defines both public (blog) and private (_blog) mounted helpers.
# The public method delegates to the private one, which creates the
# RoutesProxy. We call the private method on our dummy context (since
# the public one would fail), but record the public name for RBI output.
mounted_helpers_module.instance_methods(false).each do |method_name|
next if method_name == :main_app
# Only process the public methods (non-underscore-prefixed)
next if method_name.start_with?("_")

private_name = :"_#{method_name}"
next unless mounted_helpers_module.instance_methods(false).include?(private_name)

begin
proxy = mounted_helpers_module.instance_method(private_name).bind_call(context)
engine_routes = proxy.instance_variable_get(:@routes)
engine_class = routes_to_engine[engine_routes]
T.must(@engine_mount_names)[method_name] = engine_class if engine_class
rescue
# If we can't resolve the mapping for this mount name, skip it
next
end
end
end

# Returns `true` if `mod` "directly" includes `helper`.
# For classes, this method will return false if the `helper` is included only by a superclass
#: (Module[top] mod, Module[top] helper) -> bool
private def includes_helper?(mod, helper)
def includes_helper?(mod, helper)
ancestors = ancestors_of(mod)

own_ancestors = if Class === mod && (superclass = superclass_of(mod))
Expand Down Expand Up @@ -178,6 +277,107 @@ def generate_module_for(root, constant)
end
end

#: (RBI::Tree root) -> void
def generate_mounted_helpers_module(root)
engine_mount_names = self.class.engine_mount_names

root.create_module("GeneratedMountedHelpers") do |mod|
# main_app always returns a plain RoutesProxy
mod.create_method(
"main_app",
return_type: "ActionDispatch::Routing::RoutesProxy",
)

# One proxy method per mounted engine (only those with routes)
engine_mount_names.each do |mount_name, engine_class|
engine_name = name_of(engine_class)
next unless engine_name
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)

proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"

mod.create_method(
mount_name.to_s,
return_type: proxy_class_name,
)
end
end

# Generate GeneratedRoutesProxy class for each engine (only those with routes)
engine_mount_names.each_value do |engine_class|
engine_name = name_of(engine_class)
next unless engine_name
next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)

proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"
path_helpers_name = "#{engine_name}::GeneratedPathHelpersModule"
url_helpers_name = "#{engine_name}::GeneratedUrlHelpersModule"

root.create_class(proxy_class_name, superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass|
klass.create_include(path_helpers_name)
klass.create_include(url_helpers_name)
end
end
end

#: (Module[top] mod) -> bool
def engine_helper_module?(mod)
Rails.application.railties.grep(::Rails::Engine).any? do |engine_instance|
engine_class = engine_instance.class
next false if engine_class == Rails.application.class
next false unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)

mod == engine_class.const_get(:GeneratedPathHelpersModule) ||
mod == engine_class.const_get(:GeneratedUrlHelpersModule)
end
end

#: -> void
def generate_url_helper_includer
root.create_path(constant) do |mod|
create_mixins_for(mod, GeneratedUrlHelpersModule)
create_mixins_for(mod, GeneratedPathHelpersModule)

# GeneratedMountedHelpers is Module.new (for naming), so check
# against the real mounted_helpers module for ancestor detection.
# Only controllers/framework classes actually have mounted_helpers
# in their ancestor chain; plain url_helpers includers do not.
mounted_helpers = Rails.application.routes.mounted_helpers
include_mounted = constant.ancestors.include?(mounted_helpers) ||
NON_DISCOVERABLE_INCLUDERS.include?(constant)
extend_mounted = constant.singleton_class.ancestors.include?(mounted_helpers)

mod.create_include("GeneratedMountedHelpers") if include_mounted
mod.create_extend("GeneratedMountedHelpers") if extend_mounted

# Add engine-specific helper module mixins.
# Do NOT use create_mixins_for for engine helpers — its
# NON_DISCOVERABLE_INCLUDERS fallback would incorrectly add
# engine-specific modules to IntegrationTest and ActionView::Helpers.
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)

engine_path_mod = engine_class.const_get(:GeneratedPathHelpersModule)
engine_url_mod = engine_class.const_get(:GeneratedUrlHelpersModule)

if constant.ancestors.include?(engine_url_mod)
mod.create_include(engine_url_mod.name)
end
if constant.singleton_class.ancestors.include?(engine_url_mod)
mod.create_extend(engine_url_mod.name)
end
if constant.ancestors.include?(engine_path_mod)
mod.create_include(engine_path_mod.name)
end
if constant.singleton_class.ancestors.include?(engine_path_mod)
mod.create_extend(engine_path_mod.name)
end
end
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
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