tapioca 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -0
  3. data/lib/ruby_lsp/tapioca/run_gem_rbi_check.rb +1 -1
  4. data/lib/tapioca/cli.rb +9 -0
  5. data/lib/tapioca/commands/abstract_dsl.rb +76 -20
  6. data/lib/tapioca/commands/abstract_gem.rb +1 -0
  7. data/lib/tapioca/commands/annotations.rb +1 -1
  8. data/lib/tapioca/commands/gem_generate.rb +20 -4
  9. data/lib/tapioca/dsl/compiler.rb +24 -10
  10. data/lib/tapioca/dsl/compilers/aasm.rb +24 -17
  11. data/lib/tapioca/dsl/compilers/active_job.rb +1 -1
  12. data/lib/tapioca/dsl/compilers/active_model_attributes.rb +1 -1
  13. data/lib/tapioca/dsl/compilers/active_record_delegated_types.rb +64 -16
  14. data/lib/tapioca/dsl/compilers/active_record_fixtures.rb +17 -11
  15. data/lib/tapioca/dsl/compilers/active_record_relations.rb +2 -2
  16. data/lib/tapioca/dsl/compilers/config.rb +1 -1
  17. data/lib/tapioca/dsl/compilers/json_api_client_resource.rb +1 -1
  18. data/lib/tapioca/dsl/compilers/sidekiq_worker.rb +1 -1
  19. data/lib/tapioca/dsl/compilers/url_helpers.rb +312 -8
  20. data/lib/tapioca/dsl/compilers.rb +1 -1
  21. data/lib/tapioca/dsl/helpers/graphql_type_helper.rb +10 -3
  22. data/lib/tapioca/dsl/pipeline.rb +6 -27
  23. data/lib/tapioca/gem/events.rb +1 -1
  24. data/lib/tapioca/gem/listeners/documentation.rb +13 -8
  25. data/lib/tapioca/gem/listeners/methods.rb +79 -13
  26. data/lib/tapioca/gem/listeners/sorbet_enums.rb +1 -1
  27. data/lib/tapioca/gem/pipeline.rb +3 -3
  28. data/lib/tapioca/gemfile.rb +13 -0
  29. data/lib/tapioca/helpers/env_helper.rb +1 -1
  30. data/lib/tapioca/helpers/file_helper.rb +42 -0
  31. data/lib/tapioca/helpers/rbi_files_helper.rb +96 -5
  32. data/lib/tapioca/helpers/rbi_helper.rb +17 -3
  33. data/lib/tapioca/helpers/sorbet_helper.rb +1 -1
  34. data/lib/tapioca/internal.rb +2 -0
  35. data/lib/tapioca/loaders/gem.rb +33 -0
  36. data/lib/tapioca/loaders/loader.rb +0 -13
  37. data/lib/tapioca/rbi_ext/model.rb +16 -2
  38. data/lib/tapioca/rbs/bootsnap_cache.rb +52 -0
  39. data/lib/tapioca/rbs/rewriter.rb +91 -25
  40. data/lib/tapioca/runtime/dynamic_mixin_compiler.rb +1 -2
  41. data/lib/tapioca/runtime/generic_type_registry.rb +38 -14
  42. data/lib/tapioca/runtime/reflection.rb +18 -1
  43. data/lib/tapioca/sorbet_ext/generic_name_patch.rb +0 -20
  44. data/lib/tapioca/sorbet_ext/generic_type_patch.rb +48 -0
  45. data/lib/tapioca/version.rb +1 -1
  46. data/lib/tapioca.rb +11 -10
  47. metadata +9 -6
@@ -33,7 +33,7 @@ module Tapioca
33
33
  # include GeneratedDelegatedTypeMethods
34
34
  #
35
35
  # module GeneratedDelegatedTypeMethods
36
- # sig { params(args: T.untyped).returns(T.any(Message, Comment)) }
36
+ # sig { params(args: T.untyped).returns(T.any(::Message, ::Comment)) }
37
37
  # def build_entryable(*args); end
38
38
  #
39
39
  # sig { returns(Class) }
@@ -45,7 +45,7 @@ module Tapioca
45
45
  # sig { returns(T::Boolean) }
46
46
  # def message?; end
47
47
  #
48
- # sig { returns(T.nilable(Message)) }
48
+ # sig { returns(T.nilable(::Message)) }
49
49
  # def message; end
50
50
  #
51
51
  # sig { returns(T.nilable(Integer)) }
@@ -54,7 +54,7 @@ module Tapioca
54
54
  # sig { returns(T::Boolean) }
55
55
  # def comment?; end
56
56
  #
57
- # sig { returns(T.nilable(Comment)) }
57
+ # sig { returns(T.nilable(::Comment)) }
58
58
  # def comment; end
59
59
  #
60
60
  # sig { returns(T.nilable(Integer)) }
@@ -67,6 +67,9 @@ module Tapioca
67
67
  class ActiveRecordDelegatedTypes < Compiler
68
68
  include Helpers::ActiveRecordConstantsHelper
69
69
 
70
+ # A delegated type entry paired with the fully-qualified constant name it resolves to.
71
+ ResolvedType = Struct.new(:raw_name, :qualified_name, keyword_init: true)
72
+
70
73
  # @override
71
74
  #: -> void
72
75
  def decorate
@@ -77,8 +80,11 @@ module Tapioca
77
80
  constant.__tapioca_delegated_types.each do |role, data|
78
81
  types = data.fetch(:types)
79
82
  options = data.fetch(:options, {})
80
- populate_role_accessors(mod, role, types)
81
- populate_type_helpers(mod, role, types, options)
83
+ resolved_types = types.map do |type|
84
+ ResolvedType.new(raw_name: type, qualified_name: qualified_type_name(type, role))
85
+ end
86
+ populate_role_accessors(mod, role, resolved_types)
87
+ populate_type_helpers(mod, role, resolved_types, options)
82
88
  end
83
89
  end
84
90
 
@@ -96,8 +102,8 @@ module Tapioca
96
102
 
97
103
  private
98
104
 
99
- #: (RBI::Scope mod, Symbol role, Array[String] types) -> void
100
- def populate_role_accessors(mod, role, types)
105
+ #: (RBI::Scope mod, Symbol role, Array[ResolvedType] resolved_types) -> void
106
+ def populate_role_accessors(mod, role, resolved_types)
101
107
  mod.create_method(
102
108
  "#{role}_name",
103
109
  parameters: [],
@@ -113,20 +119,20 @@ module Tapioca
113
119
  mod.create_method(
114
120
  "build_#{role}",
115
121
  parameters: [create_rest_param("args", type: "T.untyped")],
116
- return_type: types.size == 1 ? types.first : "T.any(#{types.join(", ")})",
122
+ return_type: build_return_type(resolved_types),
117
123
  )
118
124
  end
119
125
 
120
- #: (RBI::Scope mod, Symbol role, Array[String] types, Hash[Symbol, untyped] options) -> void
121
- def populate_type_helpers(mod, role, types, options)
122
- types.each do |type|
123
- populate_type_helper(mod, role, type, options)
126
+ #: (RBI::Scope mod, Symbol role, Array[ResolvedType] resolved_types, Hash[Symbol, untyped] options) -> void
127
+ def populate_type_helpers(mod, role, resolved_types, options)
128
+ resolved_types.each do |resolved_type|
129
+ populate_type_helper(mod, role, resolved_type, options)
124
130
  end
125
131
  end
126
132
 
127
- #: (RBI::Scope mod, Symbol role, String type, Hash[Symbol, untyped] options) -> void
128
- def populate_type_helper(mod, role, type, options)
129
- singular = type.tableize.tr("/", "_").singularize
133
+ #: (RBI::Scope mod, Symbol role, ResolvedType resolved_type, Hash[Symbol, untyped] options) -> void
134
+ def populate_type_helper(mod, role, resolved_type, options)
135
+ singular = resolved_type.raw_name.tableize.tr("/", "_").singularize
130
136
  query = "#{singular}?"
131
137
  primary_key = options[:primary_key] || "id"
132
138
  role_id = options[:foreign_key] || "#{role}_id"
@@ -142,7 +148,7 @@ module Tapioca
142
148
  mod.create_method(
143
149
  singular,
144
150
  parameters: [],
145
- return_type: "T.nilable(#{type})",
151
+ return_type: "T.nilable(#{resolved_type.qualified_name})",
146
152
  )
147
153
 
148
154
  mod.create_method(
@@ -151,6 +157,48 @@ module Tapioca
151
157
  return_type: as_nilable_type(getter_type),
152
158
  )
153
159
  end
160
+
161
+ # Collapses to `T.untyped` if any member is `T.untyped`, since `T.any(::Foo, T.untyped)`
162
+ # is equivalent to `T.untyped` in Sorbet and the per-type error has already been recorded.
163
+ #: (Array[ResolvedType] resolved_types) -> String
164
+ def build_return_type(resolved_types)
165
+ qualified_types = resolved_types.map(&:qualified_name)
166
+ if qualified_types.include?("T.untyped")
167
+ "T.untyped"
168
+ elsif qualified_types.size == 1
169
+ qualified_types.fetch(0)
170
+ else
171
+ "T.any(#{qualified_types.join(", ")})"
172
+ end
173
+ end
174
+
175
+ # Resolves a delegated type entry to a fully-qualified constant name. The strings passed
176
+ # to `delegated_type(..., types: %w[...])` are written verbatim into the generated RBI,
177
+ # but the surrounding `class A::B::C` scope omits `A` and `A::B` from Sorbet's lexical
178
+ # nesting, so a bare `D` reference fails to resolve to `A::B::D` even when that constant
179
+ # exists. `compute_type` is `ActiveRecord::Base`'s own (private) namespace-walking lookup
180
+ # — the same one Rails uses for STI and polymorphic associations — so it resolves both
181
+ # bare and fully-qualified names. When the constant can't be resolved (NameError) or its
182
+ # qualified name can't be derived (anonymous class) we record a compiler error and emit
183
+ # `T.untyped`, which both surfaces the problem and keeps the generated RBI type-checkable.
184
+ #: (String type, Symbol role) -> String
185
+ def qualified_type_name(type, role)
186
+ klass = constant.send(:compute_type, type)
187
+ qualified_name = qualified_name_of(klass)
188
+ return qualified_name if qualified_name
189
+
190
+ add_unresolvable_type_error(type, role)
191
+ rescue NameError, LoadError
192
+ add_unresolvable_type_error(type, role)
193
+ end
194
+
195
+ #: (String type, Symbol role) -> String
196
+ def add_unresolvable_type_error(type, role)
197
+ add_error(<<~MSG.strip)
198
+ Cannot generate delegated_type `#{role}` on `#{constant}` since the type `#{type}` could not be resolved.
199
+ MSG
200
+ "T.untyped"
201
+ end
154
202
  end
155
203
  end
156
204
  end
@@ -26,13 +26,21 @@ module Tapioca
26
26
  # # test_case.rbi
27
27
  # # typed: true
28
28
  # class ActiveSupport::TestCase
29
- # sig { params(fixture_name: NilClass, other_fixtures: NilClass).returns(T::Array[Post]) }
30
- # sig { params(fixture_name: T.any(String, Symbol), other_fixtures: NilClass).returns(Post) }
31
- # sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol))
32
- # .returns(T::Array[Post]) }
29
+ # include ActiveRecord::TestFixtures
30
+ #
31
+ # sig { returns(T::Array[Post]) } # No names: returns an Array of all fixtures
32
+ # sig { params(fixture_name: T.any(String, Symbol)).returns(Post) } # One name: returns the requested fixture
33
+ # sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)) # Many names: returns an Array of the requested fixtures
34
+ # .returns(T::Array[Post]) }
33
35
  # def posts(fixture_name = nil, *other_fixtures); end
34
36
  # end
35
37
  # ~~~
38
+ #
39
+ # The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into
40
+ # `ActiveSupport::TestCase` through the `:active_support_test_case` load hook in
41
+ # `rails/test_help.rb`. Since RBI generation does not load an app's test helper, this runtime
42
+ # include is not captured. Without it, Sorbet does not see the class methods the module
43
+ # contributes via `mixes_in_class_methods`, such as `fixtures`.
36
44
  #: [ConstantType = singleton(ActiveSupport::TestCase)]
37
45
  class ActiveRecordFixtures < Compiler
38
46
  MISSING = Object.new
@@ -47,9 +55,10 @@ module Tapioca
47
55
  end
48
56
 
49
57
  method_names.select! { |name| fixture_class_mapping_from_fixture_files[name] != MISSING }
50
- return if method_names.empty?
51
58
 
52
59
  root.create_path(constant) do |mod|
60
+ mod.create_include("ActiveRecord::TestFixtures")
61
+
53
62
  method_names.each do |name|
54
63
  create_fixture_method(mod, name.to_s)
55
64
  end
@@ -110,19 +119,16 @@ module Tapioca
110
119
  node.add_opt_param("fixture_name", "nil")
111
120
  node.add_rest_param("other_fixtures")
112
121
 
113
- node.add_sig do |sig|
114
- sig.add_param("fixture_name", "NilClass")
115
- sig.add_param("other_fixtures", "NilClass")
122
+ node.add_sig do |sig| # No-parameter overload: returns an Array of all fixtures
116
123
  sig.return_type = "T::Array[#{return_type}]"
117
124
  end
118
125
 
119
- node.add_sig do |sig|
126
+ node.add_sig do |sig| # One parameter overload: returns the requested fixture
120
127
  sig.add_param("fixture_name", "T.any(String, Symbol)")
121
- sig.add_param("other_fixtures", "NilClass")
122
128
  sig.return_type = return_type
123
129
  end
124
130
 
125
- node.add_sig do |sig|
131
+ node.add_sig do |sig| # Multi-parameter overload: returns an Array of the requested fixtures
126
132
  sig.add_param("fixture_name", "T.any(String, Symbol)")
127
133
  sig.add_param("other_fixtures", "T.any(String, Symbol)")
128
134
  sig.return_type = "T::Array[#{return_type}]"
@@ -224,10 +224,10 @@ module Tapioca
224
224
  order: ["T.any(Symbol, T::Array[Symbol])", ":asc"],
225
225
  cursor: ["T.untyped", "primary_key"],
226
226
  use_ranges: ["T.untyped", "nil"],
227
- } #: Hash[Symbol, [String, String]]
227
+ }.freeze #: Hash[Symbol, [String, String]]
228
228
  CALCULATION_METHODS = ActiveRecord::Calculations.instance_methods(false) #: Array[Symbol]
229
229
  RELATION_METHODS = ActiveRecord::Relation.instance_methods(false) #: Array[Symbol]
230
- TO_ARRAY_METHODS = [:to_ary, :to_a] #: Array[Symbol]
230
+ TO_ARRAY_METHODS = [:to_ary, :to_a].freeze #: Array[Symbol]
231
231
 
232
232
  private
233
233
 
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- return unless defined?(Config) && defined?(Config::VERSION) && defined?(Config.const_name)
4
+ return unless defined?(Config::VERSION) && defined?(Config.const_name)
5
5
 
6
6
  module Tapioca
7
7
  module Dsl
@@ -120,7 +120,7 @@ module Tapioca
120
120
  schema = constant.schema
121
121
 
122
122
  # empty? does not exist on JsonApiClient::Schema
123
- schema if schema.size > 0 # rubocop:disable Style/ZeroLengthPredicate
123
+ schema if schema.size > 0
124
124
  end
125
125
 
126
126
  #: (RBI::Scope mod, ::JsonApiClient::Schema::Property property) -> void
@@ -46,7 +46,7 @@ module Tapioca
46
46
  # @override
47
47
  #: -> void
48
48
  def decorate
49
- return unless constant.instance_methods.include?(:perform)
49
+ return unless constant.method_defined?(:perform)
50
50
 
51
51
  root.create_path(constant) do |worker|
52
52
  method_def = constant.instance_method(:perform)
@@ -7,7 +7,22 @@ module Tapioca
7
7
  module Dsl
8
8
  module Compilers
9
9
  # `Tapioca::Dsl::Compilers::UrlHelpers` generates RBI files for classes that include or extend
10
- # [`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).
10
+ # [`Rails.application.routes.url_helpers`](https://api.rubyonrails.org/classes/ActionDispatch/Routing/UrlFor.html#module-ActionDispatch::Routing::UrlFor-label-URL+generation+for+named+routes).
11
+ #
12
+ # The compiler registers generated constants to represent the Rails route helper modules:
13
+ #
14
+ # 1. `GeneratedPathHelpersModule` holds the main application's path helpers, such as `post_path`.
15
+ #
16
+ # 2. `GeneratedUrlHelpersModule` holds the main application's URL helpers, such as `post_url`.
17
+ #
18
+ # 3. `GeneratedMountedHelpers` is a synthetic module for mounted application and engine helpers, such as
19
+ # `main_app` and `articles`. Rails exposes these helpers through an anonymous dynamic module, so the compiler creates
20
+ # a named RBI module that can be included or extended by classes that receive mounted helpers at runtime. It is
21
+ # only generated for applications that mount an engine that defines its own routes.
22
+ #
23
+ # For mounted engines, the compiler also registers engine-scoped `GeneratedPathHelpersModule` and
24
+ # `GeneratedUrlHelpersModule` constants. Mounted engine helper methods return a synthetic
25
+ # `GeneratedRoutesProxy` subclass that includes those engine-scoped helper modules.
11
26
  #
12
27
  # For example, with the following setup:
13
28
  #
@@ -16,6 +31,8 @@ module Tapioca
16
31
  # class Application < Rails::Application
17
32
  # routes.draw do
18
33
  # resource :index
34
+ #
35
+ # mount Blog::Engine, at: "/blog", as: "articles"
19
36
  # end
20
37
  # end
21
38
  # ~~~
@@ -42,6 +59,9 @@ module Tapioca
42
59
  # include ActionDispatch::Routing::UrlFor
43
60
  #
44
61
  # sig { params(args: T.untyped).returns(String) }
62
+ # def articles_path(*args); end
63
+ #
64
+ # sig { params(args: T.untyped).returns(String) }
45
65
  # def edit_index_path(*args); end
46
66
  #
47
67
  # sig { params(args: T.untyped).returns(String) }
@@ -60,6 +80,9 @@ module Tapioca
60
80
  # include ActionDispatch::Routing::UrlFor
61
81
  #
62
82
  # sig { params(args: T.untyped).returns(String) }
83
+ # def articles_url(*args); end
84
+ #
85
+ # sig { params(args: T.untyped).returns(String) }
63
86
  # def edit_index_url(*args); end
64
87
  #
65
88
  # sig { params(args: T.untyped).returns(String) }
@@ -78,6 +101,86 @@ module Tapioca
78
101
  # include GeneratedUrlHelpersModule
79
102
  # end
80
103
  # ~~~
104
+ #
105
+ # ~~~rb
106
+ # # blog/config/routes.rb
107
+ # Blog::Engine.routes.draw do
108
+ # resources :posts
109
+ # end
110
+ # ~~~
111
+ #
112
+ # ~~~rbi
113
+ # # blog/engine/generated_path_helpers_module.rbi
114
+ # # typed: true
115
+ # module Blog::Engine::GeneratedPathHelpersModule
116
+ # include ActionDispatch::Routing::PolymorphicRoutes
117
+ # include ActionDispatch::Routing::UrlFor
118
+ #
119
+ # sig { params(args: T.untyped).returns(String) }
120
+ # def edit_post_path(*args); end
121
+ #
122
+ # sig { params(args: T.untyped).returns(String) }
123
+ # def new_post_path(*args); end
124
+ #
125
+ # sig { params(args: T.untyped).returns(String) }
126
+ # def post_path(*args); end
127
+ #
128
+ # sig { params(args: T.untyped).returns(String) }
129
+ # def posts_path(*args); end
130
+ # end
131
+ # ~~~
132
+ #
133
+ # ~~~rbi
134
+ # # blog/engine/generated_url_helpers_module.rbi
135
+ # # typed: true
136
+ # module Blog::Engine::GeneratedUrlHelpersModule
137
+ # include ActionDispatch::Routing::PolymorphicRoutes
138
+ # include ActionDispatch::Routing::UrlFor
139
+ #
140
+ # sig { params(args: T.untyped).returns(String) }
141
+ # def edit_post_url(*args); end
142
+ #
143
+ # sig { params(args: T.untyped).returns(String) }
144
+ # def new_post_url(*args); end
145
+ #
146
+ # sig { params(args: T.untyped).returns(String) }
147
+ # def post_url(*args); end
148
+ #
149
+ # sig { params(args: T.untyped).returns(String) }
150
+ # def posts_url(*args); end
151
+ # end
152
+ # ~~~
153
+ #
154
+ # ~~~rbi
155
+ # # generated_mounted_helpers.rbi
156
+ # # typed: true
157
+ # module GeneratedMountedHelpers
158
+ # sig { returns(Blog::Engine::GeneratedRoutesProxy) }
159
+ # def articles; end
160
+ #
161
+ # sig { returns(GeneratedRoutesProxy) }
162
+ # def main_app; end
163
+ # end
164
+ # ~~~
165
+ #
166
+ # ~~~rbi
167
+ # # generated_routes_proxy.rbi
168
+ # # typed: true
169
+ # class GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy
170
+ # include GeneratedPathHelpersModule
171
+ # include GeneratedUrlHelpersModule
172
+ # end
173
+ # ~~~
174
+ #
175
+ # ~~~rbi
176
+ # # blog/engine/generated_routes_proxy.rbi
177
+ # # typed: true
178
+ # class Blog::Engine::GeneratedRoutesProxy < ::ActionDispatch::Routing::RoutesProxy
179
+ # include Blog::Engine::GeneratedPathHelpersModule
180
+ # include Blog::Engine::GeneratedUrlHelpersModule
181
+ # end
182
+ # ~~~
183
+ #
81
184
  #: [ConstantType = Module[top]]
82
185
  class UrlHelpers < Compiler
83
186
  # @override
@@ -87,14 +190,25 @@ module Tapioca
87
190
  when GeneratedPathHelpersModule.singleton_class, GeneratedUrlHelpersModule.singleton_class
88
191
  generate_module_for(root, constant)
89
192
  else
90
- root.create_path(constant) do |mod|
91
- create_mixins_for(mod, GeneratedUrlHelpersModule)
92
- create_mixins_for(mod, GeneratedPathHelpersModule)
193
+ # `GeneratedMountedHelpers` is only defined when an engine is mounted (see `gather_constants`).
194
+ if defined?(::GeneratedMountedHelpers) && GeneratedMountedHelpers.singleton_class === constant
195
+ generate_mounted_helpers_module(root)
196
+ elsif engine_helper_module?(constant)
197
+ generate_module_for(root, constant)
198
+ else
199
+ generate_url_helper_includer
93
200
  end
94
201
  end
95
202
  end
96
203
 
204
+ # Maps each engine's mount name to its class, e.g. `{ blog: Blog::Engine }`.
205
+ # Populated by `gather_constants` and read when generating the mounted helpers module.
206
+ @engine_mount_names = {} #: Hash[Symbol, singleton(::Rails::Engine)]
207
+
97
208
  class << self
209
+ #: Hash[Symbol, singleton(::Rails::Engine)]
210
+ attr_reader :engine_mount_names
211
+
98
212
  # @override
99
213
  #: -> Enumerable[Module[top]]
100
214
  def gather_constants
@@ -110,24 +224,55 @@ module Tapioca
110
224
  Object.const_set(:GeneratedUrlHelpersModule, url_helpers_module)
111
225
  Object.const_set(:GeneratedPathHelpersModule, path_helpers_module)
112
226
 
227
+ @engine_mount_names = mounted_engine_names
228
+ engine_helper_modules = register_engine_route_helpers
229
+
230
+ # Only synthesize the mounted helpers module when at least one mounted engine
231
+ # contributes its own route helpers. A mount of a routeless engine (or an app with
232
+ # no mounts) would leave nothing but `main_app`, which we don't generate on its own.
233
+ # This predicate mirrors `proxied_engines` in `generate_mounted_helpers_module`.
234
+ mounts_engine_with_helpers = @engine_mount_names.values.any? do |engine_class|
235
+ name_of(engine_class) && engine_class.const_defined?(:GeneratedPathHelpersModule, false)
236
+ end
237
+
238
+ if mounts_engine_with_helpers
239
+ Object.const_set(:GeneratedMountedHelpers, Module.new)
240
+ end
241
+
113
242
  constants = all_modules.select do |mod|
114
243
  next unless name_of(mod)
115
244
 
116
245
  # Fast-path to quickly disqualify most cases
117
- next false unless url_helpers_module > mod || # rubocop:disable Style/InvertibleUnlessCondition
246
+ has_helpers = url_helpers_module > mod ||
118
247
  path_helpers_module > mod ||
119
248
  url_helpers_module > mod.singleton_class ||
120
249
  path_helpers_module > mod.singleton_class
121
250
 
251
+ has_helpers ||= engine_helper_modules.any? do |engine_mod|
252
+ engine_mod > mod || engine_mod > mod.singleton_class
253
+ end
254
+
255
+ next false unless has_helpers
256
+
122
257
  includes_helper?(mod, url_helpers_module) ||
123
258
  includes_helper?(mod, path_helpers_module) ||
124
259
  includes_helper?(mod.singleton_class, url_helpers_module) ||
125
- includes_helper?(mod.singleton_class, path_helpers_module)
260
+ includes_helper?(mod.singleton_class, path_helpers_module) ||
261
+ engine_helper_modules.any? { |engine_mod| includes_helper?(mod, engine_mod) || includes_helper?(mod.singleton_class, engine_mod) }
126
262
  end
127
263
 
128
- constants.concat(NON_DISCOVERABLE_INCLUDERS).push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
264
+ constants
265
+ .concat(NON_DISCOVERABLE_INCLUDERS)
266
+ .push(GeneratedUrlHelpersModule, GeneratedPathHelpersModule)
267
+ .concat(engine_helper_modules)
268
+
269
+ constants.push(GeneratedMountedHelpers) if defined?(GeneratedMountedHelpers)
270
+
271
+ constants
129
272
  end
130
273
 
274
+ private
275
+
131
276
  #: -> Array[Module[top]]
132
277
  def gather_non_discoverable_includers
133
278
  [].tap do |includers|
@@ -141,10 +286,59 @@ module Tapioca
141
286
  end.freeze
142
287
  end
143
288
 
289
+ # Maps each mounted engine's mount name to its class (e.g. `{ articles: Blog::Engine }`).
290
+ # Reads the same route table that `bin/rails routes` inspects: a mounted engine appears
291
+ # as a route whose endpoint is the engine (`app.engine?`), with the mount name (or `as:`
292
+ # alias) as the route name.
293
+ #: -> Hash[Symbol, singleton(::Rails::Engine)]
294
+ def mounted_engine_names
295
+ Rails.application.routes.routes.each_with_object({}) do |route, mapping|
296
+ app = route.app
297
+ next unless app.respond_to?(:engine?) && app.engine?
298
+
299
+ name = route.name
300
+ next unless name
301
+
302
+ mapping[name.to_sym] = app.rack_app
303
+ end
304
+ end
305
+
306
+ # Registers engine-scoped `GeneratedPathHelpersModule`/`GeneratedUrlHelpersModule`
307
+ # constants on each engine with routes, and returns those helper modules.
308
+ #: -> Array[Module[top]]
309
+ def register_engine_route_helpers
310
+ engine_helper_modules = [] #: Array[Module[top]]
311
+
312
+ Rails.application.railties.grep(::Rails::Engine).each do |engine_instance|
313
+ engine_class = engine_instance.class
314
+ next if engine_class == Rails.application.class
315
+
316
+ engine_path_helpers = engine_instance.routes.named_routes.path_helpers_module
317
+ engine_url_helpers = engine_instance.routes.named_routes.url_helpers_module
318
+
319
+ # Skip engines with no routes
320
+ next if engine_path_helpers.instance_methods(false).empty? &&
321
+ engine_url_helpers.instance_methods(false).empty?
322
+
323
+ unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
324
+ engine_class.const_set(:GeneratedPathHelpersModule, engine_path_helpers)
325
+ end
326
+
327
+ unless engine_class.const_defined?(:GeneratedUrlHelpersModule, false)
328
+ engine_class.const_set(:GeneratedUrlHelpersModule, engine_url_helpers)
329
+ end
330
+
331
+ engine_helper_modules << engine_class.const_get(:GeneratedPathHelpersModule)
332
+ engine_helper_modules << engine_class.const_get(:GeneratedUrlHelpersModule)
333
+ end
334
+
335
+ engine_helper_modules
336
+ end
337
+
144
338
  # Returns `true` if `mod` "directly" includes `helper`.
145
339
  # For classes, this method will return false if the `helper` is included only by a superclass
146
340
  #: (Module[top] mod, Module[top] helper) -> bool
147
- private def includes_helper?(mod, helper)
341
+ def includes_helper?(mod, helper)
148
342
  ancestors = ancestors_of(mod)
149
343
 
150
344
  own_ancestors = if Class === mod && (superclass = superclass_of(mod))
@@ -178,6 +372,116 @@ module Tapioca
178
372
  end
179
373
  end
180
374
 
375
+ #: (RBI::Tree root) -> void
376
+ def generate_mounted_helpers_module(root)
377
+ # Mount name => engine name, for mounted engines that actually expose helper modules.
378
+ # Routeless engines (no `GeneratedPathHelpersModule`) and anonymous engines get no proxy.
379
+ # (If *every* mounted engine is routeless, `gather_constants` doesn't generate this module.)
380
+ proxied_engines = self.class.engine_mount_names.filter_map do |mount_name, engine_class|
381
+ engine_name = name_of(engine_class)
382
+ next unless engine_name
383
+ next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
384
+
385
+ [mount_name, engine_name]
386
+ end
387
+
388
+ root.create_module("GeneratedMountedHelpers") do |mod|
389
+ mod.create_method(
390
+ "main_app",
391
+ return_type: "GeneratedRoutesProxy",
392
+ )
393
+
394
+ proxied_engines.each do |mount_name, engine_name|
395
+ mod.create_method(
396
+ mount_name.to_s,
397
+ return_type: "#{engine_name}::GeneratedRoutesProxy",
398
+ )
399
+ end
400
+ end
401
+
402
+ # The application's own RoutesProxy subclass, returned by `main_app`. Mirrors the
403
+ # engine proxies below so that `main_app.post_path` & co. type-check.
404
+ root.create_class("GeneratedRoutesProxy", superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass|
405
+ klass.create_include("GeneratedPathHelpersModule")
406
+ klass.create_include("GeneratedUrlHelpersModule")
407
+ end
408
+
409
+ # One RoutesProxy subclass per engine, `uniq` since an engine can be mounted under
410
+ # several names but needs only a single proxy class.
411
+ proxied_engines.map { |_mount_name, engine_name| engine_name }.uniq.each do |engine_name|
412
+ proxy_class_name = "#{engine_name}::GeneratedRoutesProxy"
413
+ path_helpers_name = "#{engine_name}::GeneratedPathHelpersModule"
414
+ url_helpers_name = "#{engine_name}::GeneratedUrlHelpersModule"
415
+
416
+ root.create_class(proxy_class_name, superclass_name: "::ActionDispatch::Routing::RoutesProxy") do |klass|
417
+ klass.create_include(path_helpers_name)
418
+ klass.create_include(url_helpers_name)
419
+ end
420
+ end
421
+ end
422
+
423
+ #: (Module[top] mod) -> bool
424
+ def engine_helper_module?(mod)
425
+ Rails.application.railties.grep(::Rails::Engine).any? do |engine_instance|
426
+ engine_class = engine_instance.class
427
+ next false if engine_class == Rails.application.class
428
+ next false unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
429
+
430
+ mod == engine_class.const_get(:GeneratedPathHelpersModule) ||
431
+ mod == engine_class.const_get(:GeneratedUrlHelpersModule)
432
+ end
433
+ end
434
+
435
+ #: -> void
436
+ def generate_url_helper_includer
437
+ root.create_path(constant) do |mod|
438
+ create_mixins_for(mod, GeneratedUrlHelpersModule)
439
+ create_mixins_for(mod, GeneratedPathHelpersModule)
440
+
441
+ # GeneratedMountedHelpers is only synthesized when an engine is mounted (see
442
+ # `gather_constants`). It is a fresh `Module.new` used purely for naming, so we
443
+ # check against the real `mounted_helpers` module for ancestor detection. Only
444
+ # controllers/framework classes actually have `mounted_helpers` in their ancestor
445
+ # chain; plain url_helpers includers do not.
446
+ if defined?(::GeneratedMountedHelpers)
447
+ mounted_helpers = Rails.application.routes.mounted_helpers
448
+ include_mounted = constant.ancestors.include?(mounted_helpers) ||
449
+ NON_DISCOVERABLE_INCLUDERS.include?(constant)
450
+ extend_mounted = constant.singleton_class.ancestors.include?(mounted_helpers)
451
+
452
+ mod.create_include("GeneratedMountedHelpers") if include_mounted
453
+ mod.create_extend("GeneratedMountedHelpers") if extend_mounted
454
+ end
455
+
456
+ create_engine_helper_mixins(mod)
457
+ end
458
+ end
459
+
460
+ #: (RBI::Scope mod) -> void
461
+ def create_engine_helper_mixins(mod)
462
+ Rails.application.railties.grep(::Rails::Engine).each do |engine_instance|
463
+ engine_class = engine_instance.class
464
+ next if engine_class == Rails.application.class
465
+ next unless engine_class.const_defined?(:GeneratedPathHelpersModule, false)
466
+
467
+ create_engine_helper_mixin(mod, engine_class.const_get(:GeneratedUrlHelpersModule))
468
+ create_engine_helper_mixin(mod, engine_class.const_get(:GeneratedPathHelpersModule))
469
+ end
470
+ end
471
+
472
+ #: (RBI::Scope mod, Module[top] helper_module) -> void
473
+ def create_engine_helper_mixin(mod, helper_module)
474
+ # Engine helpers must be added only when actually present; the
475
+ # NON_DISCOVERABLE_INCLUDERS fallback is only valid for main app helpers.
476
+ if constant.ancestors.include?(helper_module)
477
+ mod.create_include(T.must(helper_module.name))
478
+ end
479
+
480
+ if constant.singleton_class.ancestors.include?(helper_module)
481
+ mod.create_extend(T.must(helper_module.name))
482
+ end
483
+ end
484
+
181
485
  #: (RBI::Scope mod, Module[top] helper_module) -> void
182
486
  def create_mixins_for(mod, helper_module)
183
487
  include_helper = constant.ancestors.include?(helper_module) || NON_DISCOVERABLE_INCLUDERS.include?(constant)
@@ -13,7 +13,7 @@ module Tapioca
13
13
  NAMESPACES = [
14
14
  "#{name}::", # compilers in this namespace
15
15
  "::", # compilers that need to be fully namespaced
16
- ] #: Array[String]
16
+ ].freeze #: Array[String]
17
17
  end
18
18
  end
19
19
  end