sorbet_view 0.17.2 → 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: 858b6bd700e41b846fc44258e8a702bb8f32f25d952a3c64c1468500619c92f0
4
- data.tar.gz: 4bbe6e97e5726844fd6135fcb148d486d01c196e33e4c1ef200f088fb79ed1a0
3
+ metadata.gz: a4a6dcbfa73d97af80ddfc038337a094df6adcfe3be2cdb95997e2eb6304993e
4
+ data.tar.gz: 5d96c0eabf3534f72abebb91738f22546f157266368d7f92cf6faf6ac38ce44e
5
5
  SHA512:
6
- metadata.gz: 48c1d98ade67fdb02c84c7650c985fe97ba35e38bdd7973fdb2586bfc4cd89e4fd654435cf5d3e9f8d6b07129cf2573ac76b10c4a857db3f919e92333a6b6b79
7
- data.tar.gz: fb8bd91ca32e0473c1a533adbf3211d4c75ddbd8283bfc980ac9497c24acb3548efd7007d13d8ae771667753fbaec1951a4215a0cf94592a959c56770288710e
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
@@ -93,15 +95,23 @@ module SorbetView
93
95
  sig { params(path: String, config: Configuration).returns(String) }
94
96
  def path_to_class_name(path, config)
95
97
  relative = strip_input_dir(path, config)
96
- basename = File.basename(relative).sub(/\..*$/, '') # strip all extensions
98
+ filename = File.basename(relative)
99
+ filename_parts = filename.split('.')
100
+
101
+ basename = T.must(filename_parts.first)
97
102
  basename = basename.delete_prefix('_') # strip partial prefix
103
+
104
+ # With 3+ parts (e.g. show.html.erb → [show, html, erb]), the last is the
105
+ # template handler and the middle parts are format/variant extensions.
106
+ # Nest them as sub-classes so show.html.erb and show.turbo_stream.erb
107
+ # don't collide as the same class name.
108
+ format_parts = filename_parts.length >= 3 ? T.must(filename_parts[1..-2]) : []
109
+
98
110
  dir = File.dirname(relative)
99
111
 
100
- parts = if dir == '.'
101
- [basename]
102
- else
103
- dir.split('/') + [basename]
104
- end
112
+ parts = dir == '.' ? [basename] : dir.split('/') + [basename]
113
+
114
+ parts += format_parts
105
115
 
106
116
  parts.map { |p| camelize(p) }.join('::')
107
117
  end
@@ -113,11 +123,12 @@ module SorbetView
113
123
 
114
124
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
115
125
  def resolve_controller_view(path, ruby_path, config)
126
+ helper_includes = resolve_runtime_helper_includes(path, config)
116
127
  new(
117
128
  class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
118
129
  superclass: '::ActionView::Base',
119
130
  includes: [
120
- '::ApplicationController::HelperMethods',
131
+ *helper_includes,
121
132
  *config.extra_includes
122
133
  ],
123
134
  template_path: path,
@@ -141,11 +152,12 @@ module SorbetView
141
152
 
142
153
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
143
154
  def resolve_layout(path, ruby_path, config)
155
+ helper_includes = resolve_runtime_helper_includes(path, config)
144
156
  new(
145
157
  class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
146
158
  superclass: '::ActionView::Base',
147
159
  includes: [
148
- '::ApplicationController::HelperMethods',
160
+ *helper_includes,
149
161
  *config.extra_includes
150
162
  ],
151
163
  template_path: path,
@@ -155,11 +167,12 @@ module SorbetView
155
167
 
156
168
  sig { params(path: String, ruby_path: String, config: Configuration).returns(TemplateContext) }
157
169
  def resolve_partial(path, ruby_path, config)
170
+ helper_includes = resolve_runtime_helper_includes(path, config)
158
171
  new(
159
172
  class_name: "SorbetView::Generated::#{path_to_class_name(path, config)}",
160
173
  superclass: '::ActionView::Base',
161
174
  includes: [
162
- '::ApplicationController::HelperMethods',
175
+ *helper_includes,
163
176
  *config.extra_includes
164
177
  ],
165
178
  template_path: path,
@@ -177,6 +190,107 @@ module SorbetView
177
190
  ruby_path: ruby_path
178
191
  )
179
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
180
294
  end
181
295
  end
182
296
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module SorbetView
5
- VERSION = '0.17.2'
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
@@ -258,19 +263,126 @@ module Tapioca
258
263
  end
259
264
  end
260
265
 
261
- # 2) Each action's template class includes the helper module and controller-specific helper modules
262
- actions = controller.action_methods.to_a
266
+ # 2) Each action's template class includes the helper module and controller-specific helper modules.
267
+ # Templates compile to format-nested classes (e.g. Users::Show::Html, Users::Show::TurboStream),
268
+ # so emit RBI for each format variant found on disk. Falls back to the action-base class
269
+ # when no format-suffixed templates exist.
270
+ actions = controller.action_methods.to_a.sort
263
271
  actions.each do |action_name|
264
272
  next unless action_name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
265
273
 
266
- class_name = "SorbetView::Generated::#{parts.join('::')}::#{camelize(action_name)}"
267
- create_class_from_path(class_name) do |klass|
268
- klass.create_include(helper_module_name) if helper_module_name
269
- controller_helper_modules.each do |mod_name|
270
- klass.create_include("::#{mod_name}")
274
+ base_class_name = "SorbetView::Generated::#{parts.join('::')}::#{camelize(action_name)}"
275
+ format_suffixes = find_template_format_suffixes(path, action_name.to_s)
276
+ class_names = format_suffixes.empty? ? [base_class_name] : format_suffixes.map { |fs| "#{base_class_name}::#{fs}" }
277
+
278
+ class_names.each do |class_name|
279
+ create_class_from_path(class_name) do |klass|
280
+ apply_includes_for_action!(klass, helper_module_name, controller_helper_modules)
271
281
  end
272
282
  end
273
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
364
+ end
365
+
366
+ # Scan view dirs for format suffixes of a given action's templates.
367
+ # e.g. ["Html", "TurboStream"] for show.html.erb + show.turbo_stream.erb.
368
+ # Templates with no format (e.g. show.erb) contribute no suffix — for those,
369
+ # callers fall back to the action-base class name.
370
+ sig { params(controller_path: String, action_name: String).returns(T::Array[String]) }
371
+ def find_template_format_suffixes(controller_path, action_name)
372
+ suffixes = T.let([], T::Array[String])
373
+
374
+ resolve_view_dirs.each do |vd|
375
+ Dir.glob(File.join(vd, controller_path, "#{action_name}.*")).each do |file|
376
+ fname_parts = File.basename(file).split('.')
377
+ next unless fname_parts.first == action_name
378
+ next unless fname_parts.length >= 3
379
+
380
+ middle_parts = T.must(fname_parts[1..-2])
381
+ suffixes << middle_parts.map { |p| camelize(p) }.join('::')
382
+ end
383
+ end
384
+
385
+ suffixes.uniq.sort
274
386
  end
275
387
 
276
388
  # Returns [[method_name, params, return_type], ...]
@@ -278,42 +390,29 @@ module Tapioca
278
390
  def extract_helper_methods(controller)
279
391
  return [] unless controller.respond_to?(:_helper_methods)
280
392
 
281
- controller._helper_methods.filter_map do |name|
393
+ rows = controller._helper_methods.filter_map do |name|
282
394
  name_s = name.to_s
283
395
  next unless name_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*[?!]?\z/)
284
396
 
285
397
  method_info = find_method_info(controller, name_s)
286
- params = if method_info
287
- build_params_from_srb_lens(method_info)
288
- else
289
- build_params_from_reflection(controller, name_s)
290
- end
398
+ params = method_info ? build_params_from_srb_lens(method_info) : build_params_from_reflection(controller, name_s)
291
399
  return_type = method_info&.return_type || 'T.untyped'
292
400
  return_type = 'T.untyped' if return_type.empty?
293
401
 
294
402
  [name_s, params, return_type]
295
403
  end
404
+ rows.sort_by { |row| T.must(row).first }
296
405
  end
297
406
 
298
- # Returns controller-specific helper module names (modules included via `helper` that are not in ApplicationController)
407
+ # Returns runtime helper module names for the controller.
299
408
  sig { params(controller: T.untyped).returns(T::Array[String]) }
300
409
  def extract_controller_helper_modules(controller)
301
410
  return [] unless controller.respond_to?(:_helpers)
302
411
 
303
- base_helpers = if defined?(::ApplicationController) && ::ApplicationController.respond_to?(:_helpers)
304
- ::ApplicationController._helpers.ancestors
305
- else
306
- []
307
- end
308
-
309
- controller_helpers = controller._helpers.ancestors
310
- specific_modules = controller_helpers - base_helpers
311
-
312
- specific_modules.filter_map do |mod|
412
+ controller._helpers.ancestors.filter_map do |mod|
313
413
  next unless mod.is_a?(Module) && !mod.is_a?(Class)
314
414
  name = mod.name
315
415
  next if name.nil? || name.empty?
316
- next if name.include?('HelperMethods')
317
416
  next if name.start_with?('ActionController::') || name.start_with?('AbstractController::')
318
417
  name
319
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.17.2
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