sorbet_view 0.18.0 → 0.19.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 017d85569d51f043ad35ece80c0782cd08055a7c2cdbef352e251ef6731edb09
4
- data.tar.gz: 868bbd6562e38ca92e892948cd08a16e069656c864738e9e9605e6951e96e25a
3
+ metadata.gz: a4a6dcbfa73d97af80ddfc038337a094df6adcfe3be2cdb95997e2eb6304993e
4
+ data.tar.gz: 5d96c0eabf3534f72abebb91738f22546f157266368d7f92cf6faf6ac38ce44e
5
5
  SHA512:
6
- metadata.gz: de2c968505b5f8b727aa4fa8f07486639786b608ae464e10eac6d11fc121c594e5e3e11962b54e5900efee3fe37acd17a2df3645f2133bed8739844464c4777f
7
- data.tar.gz: 729aea79089ee0b7f2e1d7780ea0fd4bda21857d32b43df6d2f9fbe114de76e32534da5eacb98d3f2adc6e8927608ebb4c6624087e0a711dfbc550288a4f22a0
6
+ metadata.gz: b6781e594f6fbcb73e9b7a62c9931c97c5f8cbf5375530dff6a6e4a35c7fce73e2333d586aa81e079c32d92b19c59b5b493abe27b0e638ca24e6bc9654d7987d
7
+ data.tar.gz: 7a026a08b7eb1ebddbb586799095a9de5c39159e26886bc3b0ad2c11ce8382b28bc57c79ef7768c0b929cd3c4083c49663fa11a0b7d81aeb8777b0ce3c3ebd4a
@@ -51,6 +51,8 @@ module SorbetView
51
51
  class << self
52
52
  extend T::Sig
53
53
 
54
+ @all_concrete_controllers = T.let(nil, T.nilable(T::Array[T.untyped]))
55
+
54
56
  private
55
57
 
56
58
  # Strip the matching input_dir prefix from a template path
@@ -107,11 +109,7 @@ module SorbetView
107
109
 
108
110
  dir = File.dirname(relative)
109
111
 
110
- parts = if dir == '.'
111
- [basename]
112
- else
113
- dir.split('/') + [basename]
114
- end
112
+ parts = dir == '.' ? [basename] : dir.split('/') + [basename]
115
113
 
116
114
  parts += format_parts
117
115
 
@@ -125,11 +123,12 @@ module SorbetView
125
123
 
126
124
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
127
125
  def resolve_controller_view(path, ruby_path, config)
126
+ helper_includes = resolve_runtime_helper_includes(path, config)
128
127
  new(
129
128
  class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
130
129
  superclass: '::ActionView::Base',
131
130
  includes: [
132
- '::ApplicationController::HelperMethods',
131
+ *helper_includes,
133
132
  *config.extra_includes
134
133
  ],
135
134
  template_path: path,
@@ -153,11 +152,12 @@ module SorbetView
153
152
 
154
153
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
155
154
  def resolve_layout(path, ruby_path, config)
155
+ helper_includes = resolve_runtime_helper_includes(path, config)
156
156
  new(
157
157
  class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
158
158
  superclass: '::ActionView::Base',
159
159
  includes: [
160
- '::ApplicationController::HelperMethods',
160
+ *helper_includes,
161
161
  *config.extra_includes
162
162
  ],
163
163
  template_path: path,
@@ -167,11 +167,12 @@ module SorbetView
167
167
 
168
168
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
169
169
  def resolve_partial(path, ruby_path, config)
170
+ helper_includes = resolve_runtime_helper_includes(path, config)
170
171
  new(
171
172
  class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
172
173
  superclass: '::ActionView::Base',
173
174
  includes: [
174
- '::ApplicationController::HelperMethods',
175
+ *helper_includes,
175
176
  *config.extra_includes
176
177
  ],
177
178
  template_path: path,
@@ -189,6 +190,107 @@ module SorbetView
189
190
  ruby_path: ruby_path
190
191
  )
191
192
  end
193
+
194
+ sig { params(path: String, config: Configuration).returns(T::Array[String]) }
195
+ def resolve_runtime_helper_includes(path, config)
196
+ return ['::ApplicationController::HelperMethods'] unless defined?(::ActionController::Base)
197
+
198
+ relative = strip_input_dir(path, config)
199
+ names = []
200
+ if relative.start_with?('layouts/')
201
+ names = helper_modules_for_layout(relative)
202
+ elsif File.basename(relative).start_with?('_')
203
+ names = helper_modules_for_partial(relative)
204
+ else
205
+ names = helper_modules_for_controller_view(relative)
206
+ end
207
+
208
+ return ['::ApplicationController::HelperMethods'] if names.empty?
209
+
210
+ names.map { |name| "::#{name}" }
211
+ rescue StandardError => e
212
+ $stderr.puts "[SorbetView] resolve_runtime_helper_includes failed: #{e.class}: #{e.message}"
213
+ ['::ApplicationController::HelperMethods']
214
+ end
215
+
216
+ sig { params(relative: String).returns(T::Array[String]) }
217
+ def helper_modules_for_controller_view(relative)
218
+ controller_path = File.dirname(relative)
219
+ return [] if controller_path.nil? || controller_path.empty? || controller_path == '.'
220
+
221
+ controller = find_controller_by_path(controller_path)
222
+ helper_module_names_for(controller)
223
+ end
224
+
225
+ sig { params(relative: String).returns(T::Array[String]) }
226
+ def helper_modules_for_layout(relative)
227
+ filename = File.basename(relative)
228
+ layout_name = T.must(filename.split('.').first).to_s
229
+ return [] if layout_name.empty?
230
+
231
+ all_concrete_controllers.flat_map do |controller|
232
+ next [] unless controller.respond_to?(:_layout)
233
+ next [] unless controller._layout == layout_name
234
+
235
+ helper_module_names_for(controller)
236
+ end.uniq.sort
237
+ end
238
+
239
+ sig { params(relative: String).returns(T::Array[String]) }
240
+ def helper_modules_for_partial(relative)
241
+ dir = File.dirname(relative)
242
+ return [] if dir.nil? || dir.empty? || dir == '.'
243
+
244
+ # Partials can live under subdirectories that share a parent controller
245
+ # (e.g. users/forms/_field.html.erb should use UsersController helpers).
246
+ parts = dir.split('/')
247
+ candidates = parts.length.downto(1).map { |i| parts.first(i).join('/') }
248
+
249
+ candidates.each do |controller_path|
250
+ controller = find_controller_by_path(controller_path)
251
+ names = helper_module_names_for(controller)
252
+ return names unless names.empty?
253
+ end
254
+
255
+ []
256
+ end
257
+
258
+ sig { params(controller_path: String).returns(T.untyped) }
259
+ def find_controller_by_path(controller_path)
260
+ all_concrete_controllers.find do |controller|
261
+ controller.respond_to?(:controller_path) && controller.controller_path == controller_path
262
+ end
263
+ end
264
+
265
+ sig { returns(T::Array[T.untyped]) }
266
+ def all_concrete_controllers
267
+ controllers = @all_concrete_controllers
268
+ return controllers if controllers
269
+
270
+ controllers = ObjectSpace.each_object(Class).select do |klass|
271
+ klass < ::ActionController::Base && !klass.abstract?
272
+ rescue StandardError
273
+ false
274
+ end.sort_by { |klass| (klass.name || "AnonymousController:#{klass.object_id}").to_s }
275
+
276
+ @all_concrete_controllers = controllers
277
+ controllers
278
+ end
279
+
280
+ sig { params(controller: T.untyped).returns(T::Array[String]) }
281
+ def helper_module_names_for(controller)
282
+ return [] unless controller
283
+ return [] unless controller.respond_to?(:_helpers)
284
+
285
+ # Use the actual runtime helper chain for this controller.
286
+ controller._helpers.ancestors.filter_map do |mod|
287
+ next unless mod.is_a?(Module) && !mod.is_a?(Class)
288
+ name = mod.name
289
+ next if name.nil? || name.empty?
290
+ next if name.start_with?('ActionController::') || name.start_with?('AbstractController::')
291
+ name
292
+ end.uniq
293
+ end
192
294
  end
193
295
  end
194
296
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module SorbetView
5
- VERSION = '0.18.0'
5
+ VERSION = '0.19.0'
6
6
  end
@@ -26,6 +26,7 @@ module Tapioca
26
26
  sig { override.void }
27
27
  def decorate
28
28
  @module_cache = T.let({}, T::Hash[String, RBI::Scope])
29
+ @layout_contributions = T.let({}, T::Hash[String, T::Array[T::Hash[Symbol, T.untyped]]])
29
30
  @project = T.let(SrbLens::Project.load_or_index(Dir.pwd), T.untyped)
30
31
 
31
32
  # Ensure all controllers are loaded
@@ -37,10 +38,17 @@ module Tapioca
37
38
  false
38
39
  end
39
40
 
41
+ # Deterministic order: ObjectSpace has no order guarantee, and "last controller wins" for
42
+ # shared layout classes made RBI diffs unstable. Sort by constant name; anonymous classes
43
+ # fall back to object_id.
44
+ controllers = controllers.sort_by { |c| controller_sort_key(c) }
45
+
40
46
  controllers.each do |controller|
41
47
  process_controller(controller)
42
48
  end
43
49
 
50
+ emit_merged_layout_includes
51
+
44
52
  generate_ivar_mapping(controllers)
45
53
  process_components
46
54
  compile_all_templates
@@ -61,7 +69,7 @@ module Tapioca
61
69
  controllers.each do |controller|
62
70
  path = controller.controller_path
63
71
 
64
- controller.action_methods.each do |action_name|
72
+ controller.action_methods.to_a.sort.each do |action_name|
65
73
  next unless action_name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
66
74
 
67
75
  action_ivars = extract_ivars_from_srb_lens(controller, action_name.to_s)
@@ -120,11 +128,7 @@ module Tapioca
120
128
  common_keys.each do |key|
121
129
  type_a = T.must(a[key])
122
130
  type_b = T.must(b[key])
123
- result[key] = if type_a == type_b
124
- type_a
125
- else
126
- "T.all(#{type_a}, #{type_b})"
127
- end
131
+ result[key] = type_a == type_b ? type_a : "T.all(#{type_a}, #{type_b})"
128
132
  end
129
133
  result
130
134
  end
@@ -213,7 +217,8 @@ module Tapioca
213
217
 
214
218
  sig { params(klass: T.untyped, class_name: String).void }
215
219
  def generate_component_rbi(klass, class_name)
216
- methods = klass.instance_methods(false)
220
+ # Instance method order is not guaranteed; sort for stable RBI.
221
+ methods = klass.instance_methods(false).sort
217
222
 
218
223
  method_sigs = methods.filter_map do |method_name|
219
224
  method_name_s = method_name.to_s
@@ -262,7 +267,7 @@ module Tapioca
262
267
  # Templates compile to format-nested classes (e.g. Users::Show::Html, Users::Show::TurboStream),
263
268
  # so emit RBI for each format variant found on disk. Falls back to the action-base class
264
269
  # when no format-suffixed templates exist.
265
- actions = controller.action_methods.to_a
270
+ actions = controller.action_methods.to_a.sort
266
271
  actions.each do |action_name|
267
272
  next unless action_name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
268
273
 
@@ -272,13 +277,90 @@ module Tapioca
272
277
 
273
278
  class_names.each do |class_name|
274
279
  create_class_from_path(class_name) do |klass|
275
- klass.create_include(helper_module_name) if helper_module_name
276
- controller_helper_modules.each do |mod_name|
277
- klass.create_include("::#{mod_name}")
278
- end
280
+ apply_includes_for_action!(klass, helper_module_name, controller_helper_modules)
279
281
  end
280
282
  end
281
283
  end
284
+
285
+ layout_name = controller._layout if controller.respond_to?(:_layout)
286
+ if layout_name.is_a?(String) && !layout_name.empty?
287
+ base_class_name = "SorbetView::Generated::Layouts::#{camelize(layout_name)}"
288
+ format_suffixes = find_template_format_suffixes('layouts', layout_name)
289
+ class_names = format_suffixes.empty? ? [base_class_name] : format_suffixes.map { |fs| "#{base_class_name}::#{fs}" }
290
+
291
+ class_names.each do |class_name|
292
+ contribs = T.must(@layout_contributions)[class_name] ||= []
293
+ contribs << {
294
+ controller_name: controller_sort_key(controller),
295
+ helper_module_name: helper_module_name,
296
+ controller_helper_modules: controller_helper_modules
297
+ }
298
+ end
299
+ end
300
+ end
301
+
302
+ # Stable sort key for controllers (anonymous classes have no #name).
303
+ sig { params(controller: T.untyped).returns(String) }
304
+ def controller_sort_key(controller)
305
+ (controller.name || "AnonymousController:#{controller.object_id}").to_s
306
+ end
307
+
308
+ # Apply include lines for a single controller's view class. Order: SorbetView::Helpers
309
+ # module first (if any), then controller-specific helper modules in Rails MRO order
310
+ # (see extract_controller_helper_modules).
311
+ sig do
312
+ params(
313
+ klass: T.untyped,
314
+ helper_module_name: T.nilable(String),
315
+ controller_helper_modules: T::Array[String]
316
+ ).void
317
+ end
318
+ def apply_includes_for_action!(klass, helper_module_name, controller_helper_modules)
319
+ klass.create_include(helper_module_name) if helper_module_name
320
+ controller_helper_modules.each do |mod_name|
321
+ klass.create_include("::#{mod_name}")
322
+ end
323
+ end
324
+
325
+ # Layout template classes are shared by every controller that uses the same layout.
326
+ # Record per-controller includes, then merge once with deterministic rules:
327
+ # - contributors sorted by controller_sort_key
328
+ # - within each contributor: helper module first, then MRO order (unchanged)
329
+ # - same constant name only included once: first contribution in the sorted list wins
330
+ # (preserves a single stable precedence for duplicate modules).
331
+ sig { void }
332
+ def emit_merged_layout_includes
333
+ T.must(@layout_contributions).sort_by(&:first).each do |class_name, contribs|
334
+ lines = merge_layout_include_lines(contribs)
335
+ next if lines.empty?
336
+
337
+ create_class_from_path(class_name) do |klass_rbi|
338
+ lines.each { |line| klass_rbi.create_include(line) }
339
+ end
340
+ end
341
+ end
342
+
343
+ # Returns the exact `create_include` string for each line (unprefixed SorbetView path or ::Foo).
344
+ sig { params(contributions: T::Array[T::Hash[Symbol, T.untyped]]).returns(T::Array[String]) }
345
+ def merge_layout_include_lines(contributions)
346
+ seen = T.let({}, T::Hash[String, T::Boolean])
347
+ out = T.let([], T::Array[String])
348
+
349
+ contributions.sort_by { |c| c[:controller_name].to_s }.each do |c|
350
+ h = c[:helper_module_name]
351
+ if h && !seen[h]
352
+ seen[h] = true
353
+ out << h
354
+ end
355
+ c[:controller_helper_modules].to_a.each do |mod_name|
356
+ next if seen[mod_name]
357
+
358
+ seen[mod_name] = true
359
+ out << "::#{mod_name}"
360
+ end
361
+ end
362
+
363
+ out
282
364
  end
283
365
 
284
366
  # Scan view dirs for format suffixes of a given action's templates.
@@ -300,7 +382,7 @@ module Tapioca
300
382
  end
301
383
  end
302
384
 
303
- suffixes.uniq
385
+ suffixes.uniq.sort
304
386
  end
305
387
 
306
388
  # Returns [[method_name, params, return_type], ...]
@@ -308,42 +390,29 @@ module Tapioca
308
390
  def extract_helper_methods(controller)
309
391
  return [] unless controller.respond_to?(:_helper_methods)
310
392
 
311
- controller._helper_methods.filter_map do |name|
393
+ rows = controller._helper_methods.filter_map do |name|
312
394
  name_s = name.to_s
313
395
  next unless name_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*[?!]?\z/)
314
396
 
315
397
  method_info = find_method_info(controller, name_s)
316
- params = if method_info
317
- build_params_from_srb_lens(method_info)
318
- else
319
- build_params_from_reflection(controller, name_s)
320
- end
398
+ params = method_info ? build_params_from_srb_lens(method_info) : build_params_from_reflection(controller, name_s)
321
399
  return_type = method_info&.return_type || 'T.untyped'
322
400
  return_type = 'T.untyped' if return_type.empty?
323
401
 
324
402
  [name_s, params, return_type]
325
403
  end
404
+ rows.sort_by { |row| T.must(row).first }
326
405
  end
327
406
 
328
- # Returns controller-specific helper module names (modules included via `helper` that are not in ApplicationController)
407
+ # Returns runtime helper module names for the controller.
329
408
  sig { params(controller: T.untyped).returns(T::Array[String]) }
330
409
  def extract_controller_helper_modules(controller)
331
410
  return [] unless controller.respond_to?(:_helpers)
332
411
 
333
- base_helpers = if defined?(::ApplicationController) && ::ApplicationController.respond_to?(:_helpers)
334
- ::ApplicationController._helpers.ancestors
335
- else
336
- []
337
- end
338
-
339
- controller_helpers = controller._helpers.ancestors
340
- specific_modules = controller_helpers - base_helpers
341
-
342
- specific_modules.filter_map do |mod|
412
+ controller._helpers.ancestors.filter_map do |mod|
343
413
  next unless mod.is_a?(Module) && !mod.is_a?(Class)
344
414
  name = mod.name
345
415
  next if name.nil? || name.empty?
346
- next if name.include?('HelperMethods')
347
416
  next if name.start_with?('ActionController::') || name.start_with?('AbstractController::')
348
417
  name
349
418
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sorbet_view
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kazuma
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-22 00:00:00.000000000 Z
10
+ date: 2026-05-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: herb