rigortype 0.0.1

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Shared dispatch table for `Rigor::Type#accepts(other, mode:)`.
8
+ #
9
+ # The acceptance query answers "is `other` passable to `self` at a
10
+ # method-parameter or assignment boundary?". It uses gradual-typing
11
+ # rules from docs/type-specification/value-lattice.md and the
12
+ # acceptance contract in docs/internal-spec/internal-type-api.md.
13
+ #
14
+ # Each concrete type's `accepts` method delegates here so the
15
+ # case-analysis stays in one place. Type instances remain thin value
16
+ # objects; routing logic lives in the inference layer.
17
+ #
18
+ # Slice 4 phase 2c implements the `:gradual` mode in full and
19
+ # reserves `:strict` for later slices (the entry point raises
20
+ # ArgumentError on strict for now). The table covers the leaf and
21
+ # combinator types added through phase 2b: Top, Bot, Dynamic,
22
+ # Nominal, Singleton, Constant, and Union.
23
+ #
24
+ # Slice 5 registers the shape carriers `Tuple` and `HashShape`.
25
+ # Tuple/HashShape acceptance compares per-position element types
26
+ # (covariant) and per-key entry types (depth covariant), including
27
+ # HashShape required/optional/closed-extra-key policy. When the
28
+ # receiver side is a
29
+ # generic `Nominal[Array, [E]]` or `Nominal[Hash, [K, V]]` the
30
+ # shape is projected to its underlying nominal so the existing
31
+ # generic-acceptance pipeline continues to apply; the converse
32
+ # direction (a Tuple receiver accepting a generic Array) stays
33
+ # conservative because the analyzer cannot verify arity from a
34
+ # raw nominal alone.
35
+ # rubocop:disable Metrics/ModuleLength
36
+ module Acceptance
37
+ module_function
38
+
39
+ # @param self_type [Rigor::Type]
40
+ # @param other_type [Rigor::Type]
41
+ # @param mode [Symbol] `:gradual` (default) or `:strict`.
42
+ # @return [Rigor::Type::AcceptsResult]
43
+ def accepts(self_type, other_type, mode: :gradual)
44
+ raise ArgumentError, "Acceptance mode #{mode.inspect} is not implemented yet" unless mode == :gradual
45
+
46
+ return Type::AcceptsResult.yes(mode: mode, reasons: "Bot is the empty type") if other_type.is_a?(Type::Bot)
47
+ if other_type.is_a?(Type::Dynamic)
48
+ return Type::AcceptsResult.yes(mode: mode, reasons: "gradual: Dynamic[T] passes any boundary")
49
+ end
50
+ return accepts_union_other(self_type, other_type, mode) if other_type.is_a?(Type::Union)
51
+
52
+ accepts_one(self_type, other_type, mode)
53
+ end
54
+
55
+ # Hash dispatch keeps `accepts_one` linear and lets future shape
56
+ # carriers register their handlers without re-tripping the
57
+ # cyclomatic budget on a growing `case` arm. Anonymous Type
58
+ # subclasses are not expected.
59
+ TYPE_HANDLERS = {
60
+ Type::Top => :accepts_top,
61
+ Type::Bot => :accepts_bot,
62
+ Type::Dynamic => :accepts_dynamic,
63
+ Type::Union => :accepts_union_self,
64
+ Type::Singleton => :accepts_singleton,
65
+ Type::Nominal => :accepts_nominal,
66
+ Type::Constant => :accepts_constant,
67
+ Type::Tuple => :accepts_tuple,
68
+ Type::HashShape => :accepts_hash_shape
69
+ }.freeze
70
+ private_constant :TYPE_HANDLERS
71
+
72
+ # rubocop:disable Metrics/ClassLength
73
+ class << self
74
+ private
75
+
76
+ def accepts_one(self_type, other_type, mode)
77
+ handler = TYPE_HANDLERS[self_type.class]
78
+ return send(handler, self_type, other_type, mode) if handler
79
+
80
+ Type::AcceptsResult.maybe(mode: mode, reasons: "no rule for self=#{self_type.class}")
81
+ end
82
+
83
+ def accepts_top(_self_type, _other_type, mode)
84
+ Type::AcceptsResult.yes(mode: mode, reasons: "Top is the universal type")
85
+ end
86
+
87
+ def accepts_bot(_self_type, other_type, mode)
88
+ # Other is not Bot here (handled in {.accepts}), so Bot rejects it.
89
+ Type::AcceptsResult.no(
90
+ mode: mode,
91
+ reasons: "Bot accepts only Bot, got #{other_type.class}"
92
+ )
93
+ end
94
+
95
+ # Dynamic[T] in gradual mode is liberally inhabited; any concrete
96
+ # other type is accepted because gradual consistency permits the
97
+ # crossing. (Other being Dynamic was handled in {.accepts}.)
98
+ def accepts_dynamic(_self_type, _other_type, mode)
99
+ Type::AcceptsResult.yes(
100
+ mode: mode,
101
+ reasons: "gradual: Dynamic[T] accepts any concrete type"
102
+ )
103
+ end
104
+
105
+ # Union[A,B].accepts(X) iff some member accepts X. Yes wins as
106
+ # soon as we find one; otherwise we surface "maybe" only when at
107
+ # least one member returned maybe (cannot rule out coverage),
108
+ # else "no".
109
+ def accepts_union_self(union, other_type, mode)
110
+ results = union.members.map { |m| accepts(m, other_type, mode: mode) }
111
+
112
+ if results.any?(&:yes?)
113
+ return Type::AcceptsResult.yes(
114
+ mode: mode,
115
+ reasons: "union has a member that accepts"
116
+ )
117
+ end
118
+
119
+ if results.any?(&:maybe?)
120
+ Type::AcceptsResult.maybe(mode: mode, reasons: "no union member proved acceptance")
121
+ else
122
+ Type::AcceptsResult.no(
123
+ mode: mode,
124
+ reasons: "no union member accepts #{other_type.class}"
125
+ )
126
+ end
127
+ end
128
+
129
+ # self.accepts(Union[Y, Z]) iff self accepts every Y_i. Strict
130
+ # AND across members: any "no" turns the whole result no, any
131
+ # "maybe" without a "no" gives maybe, all "yes" gives yes.
132
+ def accepts_union_other(self_type, union, mode)
133
+ results = union.members.map { |m| accepts(self_type, m, mode: mode) }
134
+
135
+ if results.any?(&:no?)
136
+ return Type::AcceptsResult.no(
137
+ mode: mode,
138
+ reasons: "a union member is rejected"
139
+ )
140
+ end
141
+
142
+ if results.any?(&:maybe?)
143
+ Type::AcceptsResult.maybe(
144
+ mode: mode,
145
+ reasons: "a union member could not be proven accepted"
146
+ )
147
+ else
148
+ Type::AcceptsResult.yes(mode: mode, reasons: "every union member accepted")
149
+ end
150
+ end
151
+
152
+ # Singleton[C] only accepts another Singleton[D] where D is a
153
+ # subclass of (or equal to) C. Any other carrier (instance,
154
+ # constant, ...) is no, because the singleton type's inhabitants
155
+ # are the class objects themselves.
156
+ def accepts_singleton(self_type, other_type, mode)
157
+ unless other_type.is_a?(Type::Singleton)
158
+ return Type::AcceptsResult.no(
159
+ mode: mode,
160
+ reasons: "Singleton[#{self_type.class_name}] does not accept #{other_type.class}"
161
+ )
162
+ end
163
+
164
+ class_subtype_result(
165
+ target_name: self_type.class_name,
166
+ actual_name: other_type.class_name,
167
+ mode: mode,
168
+ kind: :singleton
169
+ )
170
+ end
171
+
172
+ # Nominal[C] accepts:
173
+ # - Nominal[D] when D <= C (Ruby class subtype) and the
174
+ # `type_args` are compatible (see {#accepts_nominal_args});
175
+ # - Constant[v] when v.is_a?(klass(C)). The type_args of self
176
+ # are ignored here because a Constant carries a concrete
177
+ # value, not a generic instantiation, and the analyzer has no
178
+ # way to refute the args from a literal alone.
179
+ # - Tuple[*] when self is the Array (or a supertype) family.
180
+ # The Tuple is projected to `Nominal[Array, [union(elements)]]`
181
+ # so the existing generic-arg machinery handles it.
182
+ # - HashShape{*} when self is the Hash (or a supertype) family,
183
+ # projected to `Nominal[Hash, [union(keys), union(values)]]`.
184
+ # - Singleton: never (wrong value kind).
185
+ def accepts_nominal(self_type, other_type, mode)
186
+ case other_type
187
+ when Type::Nominal
188
+ accepts_nominal_from_nominal(self_type, other_type, mode)
189
+ when Type::Constant
190
+ accepts_nominal_from_constant(self_type, other_type, mode)
191
+ when Type::Tuple
192
+ accepts(self_type, project_tuple_to_nominal(other_type), mode: mode)
193
+ .with_reason("projected Tuple to Nominal[Array]")
194
+ when Type::HashShape
195
+ accepts(self_type, project_hash_shape_to_nominal(other_type), mode: mode)
196
+ .with_reason("projected HashShape to Nominal[Hash]")
197
+ else
198
+ Type::AcceptsResult.no(
199
+ mode: mode,
200
+ reasons: "Nominal[#{self_type.class_name}] rejects #{other_type.class}"
201
+ )
202
+ end
203
+ end
204
+
205
+ def accepts_nominal_from_nominal(self_type, other_type, mode)
206
+ class_result = class_subtype_result(
207
+ target_name: self_type.class_name,
208
+ actual_name: other_type.class_name,
209
+ mode: mode,
210
+ kind: :instance
211
+ )
212
+ return class_result if class_result.no?
213
+
214
+ args_result = accepts_nominal_args(self_type, other_type, mode)
215
+ combine_results(class_result, args_result, mode)
216
+ end
217
+
218
+ def project_tuple_to_nominal(tuple)
219
+ if tuple.elements.empty?
220
+ Type::Combinator.nominal_of(Array)
221
+ else
222
+ Type::Combinator.nominal_of(
223
+ Array,
224
+ type_args: [Type::Combinator.union(*tuple.elements)]
225
+ )
226
+ end
227
+ end
228
+
229
+ def project_hash_shape_to_nominal(shape)
230
+ return Type::Combinator.nominal_of(Hash) if shape.pairs.empty?
231
+
232
+ key_types = shape.pairs.keys.map { |k| Type::Combinator.constant_of(k) }
233
+ value_types = shape.pairs.values
234
+ Type::Combinator.nominal_of(
235
+ Hash,
236
+ type_args: [
237
+ Type::Combinator.union(*key_types),
238
+ Type::Combinator.union(*value_types)
239
+ ]
240
+ )
241
+ end
242
+
243
+ # Slice 4 phase 2d generic acceptance. Type arguments are
244
+ # treated covariantly element-wise (gradual default; declared
245
+ # variance lands in Slice 5+). When either side has no
246
+ # type_args we are lenient: the absent side is the "raw" form
247
+ # that historically meant "any instantiation", so we keep
248
+ # backward compatibility for call sites that have not yet
249
+ # learned to carry generics.
250
+ def accepts_nominal_args(self_type, other_type, mode)
251
+ shortcut = nominal_args_shortcut(self_type, other_type, mode)
252
+ return shortcut if shortcut
253
+
254
+ per_arg = self_type.type_args.zip(other_type.type_args).map do |formal, actual|
255
+ accepts(formal, actual, mode: mode)
256
+ end
257
+ combine_arg_results(per_arg, mode)
258
+ end
259
+
260
+ # Returns an `AcceptsResult` for the universal short-circuits
261
+ # (raw self, raw other, arity mismatch) or `nil` when the full
262
+ # element-wise check still has to run.
263
+ def nominal_args_shortcut(self_type, other_type, mode)
264
+ return Type::AcceptsResult.yes(mode: mode, reasons: "self has no type_args") if self_type.type_args.empty?
265
+ if other_type.type_args.empty?
266
+ return Type::AcceptsResult.maybe(
267
+ mode: mode,
268
+ reasons: "other has no type_args; assuming compatible (raw)"
269
+ )
270
+ end
271
+
272
+ return nil if self_type.type_args.size == other_type.type_args.size
273
+
274
+ Type::AcceptsResult.no(
275
+ mode: mode,
276
+ reasons: "type_args arity mismatch: #{self_type.type_args.size} vs #{other_type.type_args.size}"
277
+ )
278
+ end
279
+
280
+ def combine_arg_results(per_arg, mode)
281
+ if per_arg.any?(&:no?)
282
+ return Type::AcceptsResult.no(mode: mode, reasons: "a type_arg is rejected (covariant)")
283
+ end
284
+
285
+ if per_arg.any?(&:maybe?)
286
+ Type::AcceptsResult.maybe(mode: mode, reasons: "a type_arg could not be proven accepted")
287
+ else
288
+ Type::AcceptsResult.yes(mode: mode, reasons: "every type_arg accepted (covariant)")
289
+ end
290
+ end
291
+
292
+ def combine_results(class_result, args_result, mode)
293
+ combined_trinary = class_result.trinary.and(args_result.trinary)
294
+ Type::AcceptsResult.new(combined_trinary, mode: mode, reasons: class_result.reasons + args_result.reasons)
295
+ end
296
+
297
+ def accepts_nominal_from_constant(self_type, constant, mode)
298
+ ruby_class = resolve_class(self_type.class_name)
299
+ if ruby_class.nil?
300
+ return Type::AcceptsResult.maybe(
301
+ mode: mode,
302
+ reasons: "class #{self_type.class_name} not loadable; cannot prove from Constant"
303
+ )
304
+ end
305
+
306
+ if constant.value.is_a?(ruby_class)
307
+ Type::AcceptsResult.yes(mode: mode, reasons: "Constant value is_a?(#{self_type.class_name})")
308
+ else
309
+ Type::AcceptsResult.no(
310
+ mode: mode,
311
+ reasons: "Constant value is not a #{self_type.class_name}"
312
+ )
313
+ end
314
+ end
315
+
316
+ # Constant[v] accepts only Constant[v'] with structurally equal
317
+ # value. Any other type is rejected (modulo the universal
318
+ # Bot/Dynamic short-circuits already applied upstream).
319
+ def accepts_constant(self_type, other_type, mode)
320
+ if other_type.is_a?(Type::Constant) && self_type == other_type
321
+ Type::AcceptsResult.yes(mode: mode, reasons: "structural literal match")
322
+ else
323
+ Type::AcceptsResult.no(
324
+ mode: mode,
325
+ reasons: "Constant[#{self_type.value.inspect}] rejects #{other_type.class}"
326
+ )
327
+ end
328
+ end
329
+
330
+ # Tuple[A1..An] accepts:
331
+ # - Tuple[B1..Bn] when arities match and each Ai accepts Bi
332
+ # (covariant per-position).
333
+ # - Anything else: no (we cannot prove the arity from a generic
334
+ # nominal alone).
335
+ def accepts_tuple(self_type, other_type, mode)
336
+ unless other_type.is_a?(Type::Tuple)
337
+ return Type::AcceptsResult.no(
338
+ mode: mode,
339
+ reasons: "Tuple does not accept #{other_type.class}"
340
+ )
341
+ end
342
+
343
+ if self_type.elements.size != other_type.elements.size
344
+ return Type::AcceptsResult.no(
345
+ mode: mode,
346
+ reasons: "tuple arity mismatch: #{self_type.elements.size} vs #{other_type.elements.size}"
347
+ )
348
+ end
349
+
350
+ per_element = self_type.elements.zip(other_type.elements).map do |formal, actual|
351
+ accepts(formal, actual, mode: mode)
352
+ end
353
+ combine_arg_results(per_element, mode)
354
+ end
355
+
356
+ # HashShape{k1: T1, ...} accepts another HashShape when every
357
+ # required key of self is required on the other side and Ti
358
+ # accepts Ui (depth covariant). Optional keys may be absent on
359
+ # the other side; when present, their values are checked. A
360
+ # closed self rejects known or possible extra keys. Other
361
+ # types are rejected; the converse direction (a Nominal
362
+ # accepting a HashShape) is handled by `accepts_nominal` via
363
+ # projection.
364
+ def accepts_hash_shape(self_type, other_type, mode)
365
+ unless other_type.is_a?(Type::HashShape)
366
+ return Type::AcceptsResult.no(
367
+ mode: mode,
368
+ reasons: "HashShape does not accept #{other_type.class}"
369
+ )
370
+ end
371
+
372
+ missing = self_type.required_keys.reject { |key| other_type.required_key?(key) }
373
+ return hash_shape_no(mode, "HashShape missing required keys: #{missing.inspect}") unless missing.empty?
374
+
375
+ if self_type.closed?
376
+ return hash_shape_no(mode, "HashShape closed target rejects open source") if other_type.open?
377
+
378
+ extra = other_type.pairs.keys - self_type.pairs.keys
379
+ unless extra.empty?
380
+ return hash_shape_no(mode, "HashShape closed target rejects extra keys: #{extra.inspect}")
381
+ end
382
+ end
383
+
384
+ per_entry = hash_shape_entry_results(self_type, other_type, mode)
385
+ combine_arg_results(per_entry, mode)
386
+ end
387
+
388
+ def hash_shape_entry_results(self_type, other_type, mode)
389
+ self_type.pairs.filter_map do |key, formal|
390
+ next unless other_type.pairs.key?(key)
391
+
392
+ accepts(formal, other_type.pairs.fetch(key), mode: mode)
393
+ end
394
+ end
395
+
396
+ def hash_shape_no(mode, reason)
397
+ Type::AcceptsResult.no(mode: mode, reasons: reason)
398
+ end
399
+
400
+ # Slice 4 phase 2c uses Ruby's actual class hierarchy to answer
401
+ # "is D a subclass of C?". This works for any class loadable
402
+ # through Object.const_get -- core, stdlib, and live application
403
+ # classes. When either name fails to resolve we surface "maybe":
404
+ # the caller (overload selector) treats yes/maybe identically,
405
+ # so the conservative answer keeps overload coverage intact.
406
+ # Slice 5 will replace this with an RBS-driven hierarchy lookup
407
+ # so ahead-of-time type checking no longer relies on Ruby
408
+ # loading the application classes.
409
+ def class_subtype_result(target_name:, actual_name:, mode:, kind:)
410
+ return Type::AcceptsResult.yes(mode: mode, reasons: "exact name match") if target_name == actual_name
411
+
412
+ target_class = resolve_class(target_name)
413
+ actual_class = resolve_class(actual_name)
414
+ if target_class.nil? || actual_class.nil?
415
+ return Type::AcceptsResult.maybe(
416
+ mode: mode,
417
+ reasons: "subtype check unresolved (#{kind}: #{actual_name} <= #{target_name})"
418
+ )
419
+ end
420
+
421
+ if actual_class <= target_class
422
+ Type::AcceptsResult.yes(
423
+ mode: mode,
424
+ reasons: "#{actual_name} <= #{target_name} via Ruby hierarchy"
425
+ )
426
+ else
427
+ Type::AcceptsResult.no(
428
+ mode: mode,
429
+ reasons: "#{actual_name} is not a subclass of #{target_name}"
430
+ )
431
+ end
432
+ end
433
+
434
+ def resolve_class(name)
435
+ Object.const_get(name)
436
+ rescue NameError
437
+ nil
438
+ end
439
+ end
440
+ # rubocop:enable Metrics/ClassLength
441
+ end
442
+ # rubocop:enable Metrics/ModuleLength
443
+ end
444
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "multi_target_binder"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # Builds the entry scope of a block body by translating the block's
11
+ # parameter list into a `name -> Rigor::Type` map.
12
+ #
13
+ # The binder is the symmetric counterpart of {MethodParameterBinder}
14
+ # for `Prism::BlockNode`. The expected parameter types come from
15
+ # the receiving method's RBS signature
16
+ # ({Rigor::Inference::MethodDispatcher.expected_block_param_types});
17
+ # parameters that the signature does not cover (or that the binder
18
+ # cannot match by position) default to `Dynamic[Top]`. The default
19
+ # is the Slice 1 fail-soft answer for unknown values, so a block
20
+ # whose receiving method has no signature still binds every name
21
+ # into the scope (a block body whose `Local x` reads return
22
+ # `Dynamic[Top]` instead of falling through to the unbound-local
23
+ # `Dynamic[Top]` event is the same observable type, but the
24
+ # binding presence is what later slices need to attach narrowing
25
+ # facts to).
26
+ #
27
+ # MultiTargetNode parameters (`|(a, b), c|`) are bound by
28
+ # delegating each destructuring slot to
29
+ # {Rigor::Inference::MultiTargetBinder}, so a Tuple-shaped
30
+ # expected element type projects element-wise into the inner
31
+ # locals (Slice 6 phase C sub-phase 2). Numbered parameters
32
+ # (`_1`, `_2`, ...) are bound from `Prism::NumberedParametersNode`
33
+ # using the same per-position `expected_param_types:` array, so
34
+ # `[1, 2, 3].each { _1 + _2 }` sees `_1`/`_2` typed identically
35
+ # to their explicit `|x, y|` counterparts.
36
+ #
37
+ # Block-local declarations after `;` (e.g., `|x; y, z|`) are
38
+ # still skipped — they are explicitly block-local, so the outer
39
+ # scope MUST NOT observe them and the binder leaves them unbound.
40
+ #
41
+ # See docs/internal-spec/inference-engine.md for the binding contract.
42
+ # rubocop:disable Metrics/ClassLength
43
+ class BlockParameterBinder
44
+ # @param expected_param_types [Array<Rigor::Type>] positional block
45
+ # parameter types in order. Indices the binder cannot fill from
46
+ # this array (because the array is shorter than the parameter
47
+ # list, or because the slot is a kind we do not pull from the
48
+ # array) default to `Dynamic[Top]`.
49
+ def initialize(expected_param_types: [])
50
+ @expected_param_types = expected_param_types
51
+ end
52
+
53
+ # @param block_node [Prism::BlockNode]
54
+ # @return [Hash{Symbol => Rigor::Type}] ordered map from parameter
55
+ # name to bound type. Anonymous parameters are skipped;
56
+ # MultiTargetNode destructuring slots delegate to
57
+ # {MultiTargetBinder} and contribute every named local in
58
+ # declaration order. Numbered-parameter forms (`_1`, `_2`,
59
+ # ...) bind `:_1`, `:_2`, ... up to the maximum the block
60
+ # body refers to.
61
+ def bind(block_node)
62
+ params_root = block_node.parameters
63
+ return {} if params_root.nil?
64
+
65
+ case params_root
66
+ when Prism::NumberedParametersNode
67
+ bind_numbered_parameters(params_root)
68
+ when Prism::BlockParametersNode
69
+ bind_block_parameters(params_root)
70
+ else
71
+ {}
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # `|_1, _2|` numbered-parameter form. Prism exposes the
78
+ # implicit count through `NumberedParametersNode#maximum`
79
+ # (the highest `_N` referenced in the body); we materialise
80
+ # bindings for `:_1` through `:_maximum` so the block body's
81
+ # `LocalVariableReadNode` lookups see the same types as the
82
+ # equivalent explicit `|x, y|` form would.
83
+ def bind_numbered_parameters(numbered_node)
84
+ bindings = {}
85
+ numbered_node.maximum.times do |i|
86
+ bindings[:"_#{i + 1}"] = positional_type_at(i)
87
+ end
88
+ bindings
89
+ end
90
+
91
+ def bind_block_parameters(params_root)
92
+ params_node = params_root.parameters
93
+ return {} if params_node.nil?
94
+
95
+ bindings = {}
96
+ bind_positionals(params_node, bindings, 0)
97
+ bind_rest(params_node, bindings)
98
+ bind_keywords(params_node, bindings)
99
+ bind_keyword_rest(params_node, bindings)
100
+ bind_block_param(params_node, bindings)
101
+ bindings
102
+ end
103
+
104
+ def bind_positionals(params_node, bindings, cursor)
105
+ cursor = bind_required_positionals(params_node, bindings, cursor)
106
+ cursor = bind_optional_positionals(params_node, bindings, cursor)
107
+ bind_trailing_positionals(params_node, bindings, cursor)
108
+ end
109
+
110
+ def bind_required_positionals(params_node, bindings, cursor)
111
+ params_node.requireds.each do |param|
112
+ bind_required_param(param, cursor, bindings)
113
+ cursor += 1
114
+ end
115
+ cursor
116
+ end
117
+
118
+ def bind_optional_positionals(params_node, bindings, cursor)
119
+ params_node.optionals.each do |param|
120
+ bindings[param.name] = positional_type_at(cursor) if param.respond_to?(:name) && param.name
121
+ cursor += 1
122
+ end
123
+ cursor
124
+ end
125
+
126
+ def bind_trailing_positionals(params_node, bindings, cursor)
127
+ params_node.posts.each do |param|
128
+ name = required_name(param)
129
+ bindings[name] = positional_type_at(cursor) if name
130
+ cursor += 1
131
+ end
132
+ cursor
133
+ end
134
+
135
+ # `|*rest|` binds an Array of the leftover positional arguments.
136
+ # The expected-types array is per-position, not per-rest; we
137
+ # cannot reliably pick a single element type for rest, so we
138
+ # default to `Array[Dynamic[Top]]`. Slice C sub-phase 2 may
139
+ # tighten this when the receiving method's RBS rest type is
140
+ # available.
141
+ def bind_rest(params_node, bindings)
142
+ rest = params_node.rest
143
+ return unless rest.respond_to?(:name) && rest&.name
144
+
145
+ bindings[rest.name] = Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
146
+ end
147
+
148
+ def bind_keywords(params_node, bindings)
149
+ params_node.keywords.each do |kw|
150
+ case kw
151
+ when Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode
152
+ bindings[kw.name] = Type::Combinator.untyped
153
+ end
154
+ end
155
+ end
156
+
157
+ def bind_keyword_rest(params_node, bindings)
158
+ kw_rest = params_node.keyword_rest
159
+ return unless kw_rest.respond_to?(:name) && kw_rest&.name
160
+
161
+ symbol_nominal = Type::Combinator.nominal_of("Symbol")
162
+ bindings[kw_rest.name] = Type::Combinator.nominal_of(
163
+ "Hash",
164
+ type_args: [symbol_nominal, Type::Combinator.untyped]
165
+ )
166
+ end
167
+
168
+ def bind_block_param(params_node, bindings)
169
+ block = params_node.block
170
+ return unless block.respond_to?(:name) && block&.name
171
+
172
+ bindings[block.name] = Type::Combinator.nominal_of(Proc)
173
+ end
174
+
175
+ # Required parameters in a block list can be either a plain
176
+ # `RequiredParameterNode` (named) or a `MultiTargetNode` (the
177
+ # `|(a, b), c|` destructuring form). Slice 6 phase C sub-phase 2
178
+ # delegates the latter to {MultiTargetBinder}, which decomposes
179
+ # the slot's expected Tuple element-wise and binds every named
180
+ # inner local. Other shapes (anonymous required parameters,
181
+ # forward arguments) are silently skipped.
182
+ def bind_required_param(param, cursor, bindings)
183
+ case param
184
+ when Prism::RequiredParameterNode
185
+ bindings[param.name] = positional_type_at(cursor)
186
+ when Prism::MultiTargetNode
187
+ nested = MultiTargetBinder.bind(param, positional_type_at(cursor))
188
+ bindings.merge!(nested)
189
+ end
190
+ end
191
+
192
+ def positional_type_at(index)
193
+ @expected_param_types[index] || Type::Combinator.untyped
194
+ end
195
+ end
196
+ # rubocop:enable Metrics/ClassLength
197
+ end
198
+ end