rigortype 0.0.3 → 0.0.5

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +215 -117
  3. data/data/builtins/ruby_core/comparable.yml +87 -0
  4. data/data/builtins/ruby_core/complex.yml +505 -0
  5. data/data/builtins/ruby_core/date.yml +1737 -0
  6. data/data/builtins/ruby_core/enumerable.yml +557 -0
  7. data/data/builtins/ruby_core/file.yml +9 -0
  8. data/data/builtins/ruby_core/hash.yml +936 -0
  9. data/data/builtins/ruby_core/range.yml +389 -0
  10. data/data/builtins/ruby_core/rational.yml +365 -0
  11. data/data/builtins/ruby_core/set.yml +594 -0
  12. data/data/builtins/ruby_core/string.yml +9 -0
  13. data/data/builtins/ruby_core/time.yml +752 -0
  14. data/lib/rigor/analysis/check_rules.rb +11 -3
  15. data/lib/rigor/builtins/imported_refinements.rb +192 -10
  16. data/lib/rigor/cli.rb +1 -1
  17. data/lib/rigor/inference/acceptance.rb +181 -12
  18. data/lib/rigor/inference/builtins/comparable_catalog.rb +27 -0
  19. data/lib/rigor/inference/builtins/complex_catalog.rb +41 -0
  20. data/lib/rigor/inference/builtins/date_catalog.rb +98 -0
  21. data/lib/rigor/inference/builtins/enumerable_catalog.rb +27 -0
  22. data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
  23. data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
  24. data/lib/rigor/inference/builtins/rational_catalog.rb +38 -0
  25. data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
  26. data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +145 -11
  28. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +202 -1
  29. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +95 -0
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +135 -6
  32. data/lib/rigor/inference/method_dispatcher.rb +3 -1
  33. data/lib/rigor/inference/method_parameter_binder.rb +29 -4
  34. data/lib/rigor/inference/narrowing.rb +211 -0
  35. data/lib/rigor/inference/scope_indexer.rb +87 -11
  36. data/lib/rigor/inference/statement_evaluator.rb +6 -0
  37. data/lib/rigor/rbs_extended.rb +170 -14
  38. data/lib/rigor/type/combinator.rb +90 -0
  39. data/lib/rigor/type/integer_range.rb +4 -2
  40. data/lib/rigor/type/intersection.rb +135 -0
  41. data/lib/rigor/type/refined.rb +174 -0
  42. data/lib/rigor/type.rb +2 -0
  43. data/lib/rigor/version.rb +1 -1
  44. data/sig/rigor/environment.rbs +4 -6
  45. data/sig/rigor/inference.rbs +2 -1
  46. data/sig/rigor/rbs_extended.rbs +11 -0
  47. data/sig/rigor/type.rbs +75 -35
  48. metadata +22 -1
@@ -27,18 +27,25 @@ module Rigor
27
27
  # - `a.downto(b) { |i| … }` yields the same domain `[b, a]`,
28
28
  # just iterated in reverse. Lower bound from the
29
29
  # argument, upper bound from the receiver.
30
- module IteratorDispatch
30
+ module IteratorDispatch # rubocop:disable Metrics/ModuleLength
31
31
  module_function
32
32
 
33
33
  # @return [Array<Rigor::Type>, nil] block-param types, or
34
34
  # nil to fall through to the next tier.
35
+ # rubocop:disable Metrics/CyclomaticComplexity
35
36
  def block_param_types(receiver:, method_name:, args:)
36
37
  case method_name
37
38
  when :times then times_block_params(receiver)
38
39
  when :upto then upto_block_params(receiver, args.first)
39
40
  when :downto then downto_block_params(receiver, args.first)
41
+ when :each_with_index then each_with_index_block_params(receiver)
42
+ when :each_with_object then each_with_object_block_params(receiver, args.first)
43
+ when :inject, :reduce then inject_block_params(receiver, args)
44
+ when :group_by, :partition then single_element_block_params(receiver)
45
+ when :each_slice, :each_cons then slice_block_params(receiver)
40
46
  end
41
47
  end
48
+ # rubocop:enable Metrics/CyclomaticComplexity
42
49
 
43
50
  def times_block_params(receiver)
44
51
  return nil unless integer_rooted?(receiver)
@@ -62,6 +69,200 @@ module Rigor
62
69
  [build_index_range(lower_bound_of(end_arg), upper_bound_of(receiver))]
63
70
  end
64
71
 
72
+ # Generalised iterator: every Enumerable-shaped collection
73
+ # in v0.0.4 yields `(element, index)` where the index is
74
+ # always `non-negative-int`. The element comes from the
75
+ # receiver's shape:
76
+ #
77
+ # - `Array[T]` / `Set[T]` / `Range[T]` → T
78
+ # - `Tuple[A, B, C]` → A | B | C
79
+ # (empty tuple cannot iterate, but we conservatively
80
+ # fall through to RBS so a missing rule never throws)
81
+ # - `Hash[K, V]` / `HashShape{...}` → Tuple[K, V]
82
+ # (Ruby yields `[key, value]` pairs as the element)
83
+ # - `Constant<Array>` / `Constant<Range>` / `Constant<Set>`
84
+ # → corresponding Constant element
85
+ #
86
+ # Receivers we cannot project (Top, Dynamic, unknown
87
+ # nominals, IO, …) decline so the RBS tier still answers
88
+ # — its element type is correct, only the index would
89
+ # widen to plain Integer.
90
+ def each_with_index_block_params(receiver)
91
+ element = element_type_of(receiver)
92
+ return nil if element.nil?
93
+
94
+ [element, Type::Combinator.non_negative_int]
95
+ end
96
+
97
+ # `each_with_object(memo) { |elem, memo_inner| … }` yields
98
+ # `(element, memo)` where `memo` is the second argument's
99
+ # type (passed by reference and threaded across iterations
100
+ # at runtime — Rigor reflects that by binding the block's
101
+ # second parameter to whatever the call site supplied).
102
+ # When the call has no memo argument the dispatcher
103
+ # declines so the user's RBS / overload selector decides.
104
+ def each_with_object_block_params(receiver, memo_arg)
105
+ return nil if memo_arg.nil?
106
+
107
+ element = element_type_of(receiver)
108
+ return nil if element.nil?
109
+
110
+ [element, memo_arg]
111
+ end
112
+
113
+ # `inject(seed) { |memo, elem| … }` and `reduce` accept
114
+ # three call shapes:
115
+ #
116
+ # - `(seed) { |memo, elem| … }` — block params `[seed, element]`.
117
+ # The memo's static type is the seed's; we cannot prove the
118
+ # block return type here without round-tripping through the
119
+ # block analyser, so the binding is the seed's type and
120
+ # downstream inference widens as needed.
121
+ # - `() { |memo, elem| … }` — the first iteration uses the
122
+ # first element as the memo, so `[element, element]` is the
123
+ # sound binding.
124
+ # - `(seed, :sym)` / `(:sym)` — Symbol method-name forms have
125
+ # no block. `inject` with a Symbol final arg is recognised
126
+ # and declined (returns nil) so the dispatcher does not
127
+ # pretend a block existed.
128
+ def inject_block_params(receiver, args)
129
+ element = element_type_of(receiver)
130
+ return nil if element.nil?
131
+
132
+ case args.size
133
+ when 0
134
+ [element, element]
135
+ when 1
136
+ seed = args.first
137
+ return nil if symbol_constant?(seed)
138
+
139
+ [seed, element]
140
+ when 2
141
+ # `inject(seed, :sym)` — Symbol-call form, no block.
142
+ return nil if symbol_constant?(args[1])
143
+
144
+ [args[0], element]
145
+ end
146
+ end
147
+
148
+ def symbol_constant?(type)
149
+ type.is_a?(Type::Constant) && type.value.is_a?(Symbol)
150
+ end
151
+
152
+ # Element-yielding Enumerable methods covered as a v0.0.5
153
+ # placeholder. RBS already binds the block parameter
154
+ # correctly for plain `Array[T]` / `Set[T]` / `Range[T]`
155
+ # receivers via generic substitution; this tier exists so
156
+ # Tuple- and HashShape-shaped receivers reach the block
157
+ # body with the precise per-position element union /
158
+ # `Tuple[K, V]` pair rather than the projected
159
+ # `Array[union]` / `Hash[K, V]` widening.
160
+ #
161
+ # NOTE (v0.0.5): the per-method coverage here (group_by,
162
+ # partition, each_slice, each_cons) is intentionally
163
+ # narrow. The longer-term direction is to move
164
+ # Enumerable-aware projections into a plugin tier modelled
165
+ # after PHPStan's extension API (ADR-2). The placeholders
166
+ # below stay until the plugin surface is in place; once it
167
+ # ships, this dispatcher loses these arms and the
168
+ # equivalent rules move into a built-in plugin loaded at
169
+ # boot.
170
+ def single_element_block_params(receiver)
171
+ element = element_type_of(receiver)
172
+ return nil if element.nil?
173
+
174
+ [element]
175
+ end
176
+
177
+ # `each_slice(n) { |slice| … }` and `each_cons(n) { |window| … }`
178
+ # both yield an `Array[element]` once per iteration. The
179
+ # tier ignores the slice-size argument (a Constant<Integer>
180
+ # `n` could in principle bound the slice's length, but a
181
+ # tighter Tuple-of-`n` carrier is reserved for the plugin
182
+ # tier per the NOTE above).
183
+ def slice_block_params(receiver)
184
+ element = element_type_of(receiver)
185
+ return nil if element.nil?
186
+
187
+ [Type::Combinator.nominal_of("Array", type_args: [element])]
188
+ end
189
+
190
+ ELEMENT_BY_NOMINAL = {
191
+ "Array" => :nominal_unary_element,
192
+ "Set" => :nominal_unary_element,
193
+ "Range" => :nominal_unary_element,
194
+ "Hash" => :nominal_hash_pair_element
195
+ }.freeze
196
+ private_constant :ELEMENT_BY_NOMINAL
197
+
198
+ def element_type_of(receiver)
199
+ case receiver
200
+ when Type::Tuple then tuple_element(receiver)
201
+ when Type::HashShape then hash_shape_pair_element(receiver)
202
+ when Type::Nominal then nominal_element(receiver)
203
+ when Type::Constant then constant_element(receiver)
204
+ end
205
+ end
206
+
207
+ def tuple_element(tuple)
208
+ return nil if tuple.elements.empty?
209
+ return tuple.elements.first if tuple.elements.size == 1
210
+
211
+ Type::Combinator.union(*tuple.elements)
212
+ end
213
+
214
+ def hash_shape_pair_element(shape)
215
+ return nil if shape.pairs.empty?
216
+
217
+ key = Type::Combinator.union(*shape.pairs.keys.map { |k| Type::Combinator.constant_of(k) })
218
+ value = Type::Combinator.union(*shape.pairs.values)
219
+ Type::Combinator.tuple_of(key, value)
220
+ end
221
+
222
+ def nominal_element(nominal)
223
+ handler = ELEMENT_BY_NOMINAL[nominal.class_name]
224
+ return nil unless handler
225
+
226
+ send(handler, nominal)
227
+ end
228
+
229
+ def nominal_unary_element(nominal)
230
+ nominal.type_args.first
231
+ end
232
+
233
+ def nominal_hash_pair_element(nominal)
234
+ key, value = nominal.type_args
235
+ return nil if key.nil? || value.nil?
236
+
237
+ Type::Combinator.tuple_of(key, value)
238
+ end
239
+
240
+ def constant_element(constant)
241
+ case constant.value
242
+ when Array
243
+ return nil if constant.value.empty?
244
+
245
+ Type::Combinator.union(*constant.value.map { |v| Type::Combinator.constant_of(v) })
246
+ when Range
247
+ range_constant_element(constant.value)
248
+ end
249
+ end
250
+
251
+ def range_constant_element(range)
252
+ beg = range.begin
253
+ en = range.end
254
+ return Type::Combinator.constant_of(beg) if beg.is_a?(Integer) && beg == en
255
+
256
+ if beg.is_a?(Integer) && en.is_a?(Integer)
257
+ upper = range.exclude_end? ? en - 1 : en
258
+ return build_index_range(beg, upper)
259
+ end
260
+
261
+ # Mixed / non-integer ranges decline: the dispatcher
262
+ # falls through to RBS's element-type answer.
263
+ nil
264
+ end
265
+
65
266
  # `Constant<Integer>`, `IntegerRange`, and `Nominal[Integer]`
66
267
  # all participate. Non-integer types (Float, String, …) and
67
268
  # `Top`/`Dynamic` decline so the RBS tier answers.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Kernel intrinsic shape-folding — precision tier for the
9
+ # `Kernel` module-functions whose return type is a function
10
+ # of the argument's *shape*, not just its class.
11
+ #
12
+ # Today the only catalogued intrinsic is `Kernel#Array`. The
13
+ # default RBS sig is `Array(untyped) -> Array[untyped]`, which
14
+ # collapses to `Array[Dynamic[top]]` for every caller. This
15
+ # tier short-circuits with a precise answer when the argument's
16
+ # type lattice tells us what the result element type MUST be:
17
+ #
18
+ # Array(Constant[nil]) -> Array[bot] # `[]`
19
+ # Array(Nominal["Array",[E]]) -> Array[E] # already an Array
20
+ # Array(Tuple[T1,T2,…]) -> Array[T1|T2|…]
21
+ # Array(Union[A,B,…]) -> distribute, then unify
22
+ # Array(other Nominal[T]) -> Array[Nominal[T]]
23
+ #
24
+ # For receiver shapes we cannot prove (`Top`, `Dynamic`, …)
25
+ # the tier returns nil and the RBS tier answers with the
26
+ # generic `Array[untyped]` envelope.
27
+ #
28
+ # See `docs/type-specification/value-lattice.md` for the
29
+ # union-distribution contract this tier mirrors.
30
+ module KernelDispatch
31
+ module_function
32
+
33
+ def try_dispatch(receiver:, method_name:, args:)
34
+ return nil unless method_name == :Array
35
+ return nil if args.length != 1
36
+ return nil if receiver.nil?
37
+
38
+ arg = args.first
39
+ element = element_type_of(arg)
40
+ return nil if element.nil?
41
+
42
+ Type::Combinator.nominal_of("Array", type_args: [element])
43
+ end
44
+
45
+ # Computes the element type the argument contributes to the
46
+ # `Array(arg)` result, mirroring Ruby's coercion contract:
47
+ #
48
+ # - `nil` becomes `[]` (element type Bot — the empty array
49
+ # contributes no inhabitants).
50
+ # - An existing `Array[E]` is returned as-is, so its element
51
+ # type is `E`.
52
+ # - A `Tuple[T1, T2, …]` is materialised as `Array[T1|T2|…]`
53
+ # (every tuple inhabitant is a tuple, hence Array-like).
54
+ # - Any other value `v` becomes `[v]`, so the element type
55
+ # is the value's own type.
56
+ #
57
+ # Returns nil for receiver shapes the tier cannot prove
58
+ # (Top, Dynamic, Bot in pre-coercion position) so the
59
+ # caller falls back to the RBS-tier envelope.
60
+ def element_type_of(type)
61
+ case type
62
+ when Type::Union
63
+ distribute_over_union(type)
64
+ when Type::Constant
65
+ type.value.nil? ? Type::Combinator.bot : type
66
+ when Type::Nominal
67
+ array_element_or_self(type)
68
+ when Type::Tuple
69
+ tuple_element_union(type)
70
+ end
71
+ end
72
+
73
+ def distribute_over_union(union)
74
+ contributions = union.members.map { |member| element_type_of(member) }
75
+ return nil if contributions.any?(&:nil?)
76
+
77
+ Type::Combinator.union(*contributions)
78
+ end
79
+
80
+ def array_element_or_self(nominal)
81
+ return nominal unless nominal.class_name == "Array"
82
+ return Type::Combinator.untyped if nominal.type_args.empty?
83
+
84
+ Type::Combinator.union(*nominal.type_args)
85
+ end
86
+
87
+ def tuple_element_union(tuple)
88
+ return Type::Combinator.bot if tuple.elements.empty?
89
+
90
+ Type::Combinator.union(*tuple.elements)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -53,13 +53,22 @@ module Rigor
53
53
  overloads = method_definition.method_types
54
54
  return nil if overloads.empty?
55
55
 
56
+ # `rigor:v1:param: <name> <refinement>` annotations on
57
+ # this method override the RBS-declared parameter type
58
+ # at the matching name. The map is consumed inside
59
+ # `accepts_param?` so overload selection sees the
60
+ # tighter type when filtering candidates by argument
61
+ # compatibility.
62
+ param_overrides = RbsExtended.param_type_override_map(method_definition)
63
+
56
64
  match = find_matching_overload(
57
65
  overloads,
58
66
  arg_types: arg_types,
59
67
  self_type: self_type,
60
68
  instance_type: instance_type,
61
69
  type_vars: type_vars,
62
- block_required: block_required
70
+ block_required: block_required,
71
+ param_overrides: param_overrides
63
72
  )
64
73
  return match if match
65
74
  return overloads.find { |mt| overload_has_block?(mt) } if block_required
@@ -76,7 +85,8 @@ module Rigor
76
85
  private
77
86
 
78
87
  # rubocop:disable Metrics/ParameterLists
79
- def find_matching_overload(overloads, arg_types:, self_type:, instance_type:, type_vars:, block_required:)
88
+ def find_matching_overload(overloads, arg_types:, self_type:, instance_type:, type_vars:, block_required:,
89
+ param_overrides:)
80
90
  overloads.find do |method_type|
81
91
  next false if block_required && !OverloadSelector.overload_has_block?(method_type)
82
92
 
@@ -85,13 +95,15 @@ module Rigor
85
95
  arg_types,
86
96
  self_type: self_type,
87
97
  instance_type: instance_type,
88
- type_vars: type_vars
98
+ type_vars: type_vars,
99
+ param_overrides: param_overrides
89
100
  )
90
101
  end
91
102
  end
92
103
  # rubocop:enable Metrics/ParameterLists
93
104
 
94
- def matches?(method_type, arg_types, self_type:, instance_type:, type_vars:)
105
+ # rubocop:disable Metrics/ParameterLists
106
+ def matches?(method_type, arg_types, self_type:, instance_type:, type_vars:, param_overrides:)
95
107
  return false if method_type.respond_to?(:type_params) && rejects_keyword_required?(method_type)
96
108
 
97
109
  fun = method_type.type
@@ -104,10 +116,12 @@ module Rigor
104
116
  arg,
105
117
  self_type: self_type,
106
118
  instance_type: instance_type,
107
- type_vars: type_vars
119
+ type_vars: type_vars,
120
+ param_overrides: param_overrides
108
121
  )
109
122
  end
110
123
  end
124
+ # rubocop:enable Metrics/ParameterLists
111
125
 
112
126
  # Slice 4 phase 2c does not pass keyword arguments through the
113
127
  # call site (caller passes only positional `arg_types`). An
@@ -152,8 +166,9 @@ module Rigor
152
166
  head
153
167
  end
154
168
 
155
- def accepts_param?(param, arg, self_type:, instance_type:, type_vars:)
156
- param_type = RbsTypeTranslator.translate(
169
+ # rubocop:disable Metrics/ParameterLists
170
+ def accepts_param?(param, arg, self_type:, instance_type:, type_vars:, param_overrides:)
171
+ param_type = param_overrides[param.name] || RbsTypeTranslator.translate(
157
172
  param.type,
158
173
  self_type: self_type,
159
174
  instance_type: instance_type,
@@ -162,6 +177,7 @@ module Rigor
162
177
  result = param_type.accepts(arg, mode: :gradual)
163
178
  result.yes? || result.maybe?
164
179
  end
180
+ # rubocop:enable Metrics/ParameterLists
165
181
  end
166
182
  end
167
183
  end
@@ -88,14 +88,25 @@ module Rigor
88
88
 
89
89
  # @return [Rigor::Type, nil] the precise element/value type, or
90
90
  # `nil` to defer to the next dispatcher tier.
91
+ # Per-carrier dispatch table. Adding a new carrier here
92
+ # is a one-row change; the helper methods stay private.
93
+ # Anonymous Type subclasses are not expected.
94
+ RECEIVER_HANDLERS = {
95
+ Type::Tuple => :dispatch_tuple,
96
+ Type::HashShape => :dispatch_hash_shape,
97
+ Type::Nominal => :dispatch_nominal_size,
98
+ Type::Difference => :dispatch_difference,
99
+ Type::Refined => :dispatch_refined,
100
+ Type::Intersection => :dispatch_intersection
101
+ }.freeze
102
+ private_constant :RECEIVER_HANDLERS
103
+
91
104
  def try_dispatch(receiver:, method_name:, args:)
92
105
  args ||= []
93
- case receiver
94
- when Type::Tuple then dispatch_tuple(receiver, method_name, args)
95
- when Type::HashShape then dispatch_hash_shape(receiver, method_name, args)
96
- when Type::Nominal then dispatch_nominal_size(receiver, method_name, args)
97
- when Type::Difference then dispatch_difference(receiver, method_name, args)
98
- end
106
+ handler = RECEIVER_HANDLERS[receiver.class]
107
+ return nil unless handler
108
+
109
+ send(handler, receiver, method_name, args)
99
110
  end
100
111
 
101
112
  # Tightens `Array#size` / `Array#length` / `String#length` /
@@ -220,6 +231,124 @@ module Rigor
220
231
  Type::Combinator.positive_int
221
232
  end
222
233
 
234
+ # Predicate-subset projections over a `Refined[base,
235
+ # predicate]` receiver. Today the catalogue is the
236
+ # String case-normalisation pair: `s.downcase` over a
237
+ # `lowercase-string` receiver folds to the same
238
+ # carrier (already lowercase), and `s.upcase` lifts a
239
+ # `lowercase-string` to `uppercase-string`. Symmetric
240
+ # rules apply with the predicates swapped. Numeric-
241
+ # string idempotence over `#downcase` / `#upcase` is
242
+ # also recognised because a numeric string equals its
243
+ # own case-normalisation.
244
+ #
245
+ # For methods this tier does not have a refinement-
246
+ # specific rule for, projection delegates to
247
+ # `dispatch_nominal_size` so size-returning calls on
248
+ # a `Refined[String, *]` still tighten to
249
+ # `non_negative_int`.
250
+ REFINED_STRING_PROJECTIONS = {
251
+ %i[lowercase downcase] => :refined_self,
252
+ %i[lowercase upcase] => :uppercase_string,
253
+ %i[uppercase upcase] => :refined_self,
254
+ %i[uppercase downcase] => :lowercase_string,
255
+ %i[numeric downcase] => :refined_self,
256
+ %i[numeric upcase] => :refined_self,
257
+ # Digit-only strings are case-invariant; the prefix
258
+ # letters in `0o…` / `0x…` are accepted by the
259
+ # predicate in either case so the predicate-subset
260
+ # is preserved across `#downcase` / `#upcase` even
261
+ # though the value-set element changes.
262
+ %i[decimal_int downcase] => :refined_self,
263
+ %i[decimal_int upcase] => :refined_self,
264
+ %i[octal_int downcase] => :refined_self,
265
+ %i[octal_int upcase] => :refined_self,
266
+ %i[hex_int downcase] => :refined_self,
267
+ %i[hex_int upcase] => :refined_self
268
+ }.freeze
269
+ private_constant :REFINED_STRING_PROJECTIONS
270
+
271
+ def dispatch_refined(refined, method_name, args)
272
+ base = refined.base
273
+ return nil unless base.is_a?(Type::Nominal)
274
+
275
+ if base.class_name == "String" && args.empty?
276
+ precise = refined_string_projection(refined, method_name)
277
+ return precise if precise
278
+ end
279
+
280
+ dispatch_nominal_size(base, method_name, args)
281
+ end
282
+
283
+ def refined_string_projection(refined, method_name)
284
+ handler = REFINED_STRING_PROJECTIONS[[refined.predicate_id, method_name]]
285
+ return nil unless handler
286
+
287
+ case handler
288
+ when :refined_self then refined
289
+ when :uppercase_string then Type::Combinator.uppercase_string
290
+ when :lowercase_string then Type::Combinator.lowercase_string
291
+ end
292
+ end
293
+
294
+ # Projects a method call over an `Intersection[M1, …]`
295
+ # receiver by collecting each member's projection and
296
+ # combining the results. The set-theoretic identity is
297
+ # `M(A ∩ B) ⊆ M(A) ∩ M(B)`, so the meet of the per-member
298
+ # projections is sound. Combining is best-effort:
299
+ #
300
+ # - If every result is a `Type::IntegerRange`, return
301
+ # their bounded-integer meet (max of lower bounds, min
302
+ # of upper bounds). This catches the common
303
+ # `(non_empty_string ∩ lowercase_string).size`
304
+ # pattern where one member projects to `positive-int`
305
+ # and the other to `non-negative-int`; the meet is
306
+ # `positive-int`.
307
+ # - Otherwise return the first non-nil result. A richer
308
+ # meet (e.g. of Difference + Refined results when both
309
+ # project) is left for a future slice; the carrier
310
+ # stays sound because every member's projection is
311
+ # already a superset of the true intersection.
312
+ #
313
+ # Returns nil when no member projects, so the caller
314
+ # falls through to the next dispatcher tier.
315
+ def dispatch_intersection(intersection, method_name, args)
316
+ results = intersection.members.filter_map do |member|
317
+ ShapeDispatch.try_dispatch(receiver: member, method_name: method_name, args: args)
318
+ end
319
+
320
+ case results.size
321
+ when 0 then nil
322
+ when 1 then results.first
323
+ else combine_intersection_results(results)
324
+ end
325
+ end
326
+
327
+ def combine_intersection_results(results)
328
+ return narrow_integer_ranges(results) if results.all?(Type::IntegerRange)
329
+
330
+ results.first
331
+ end
332
+
333
+ # Compute the bounded-integer meet of two or more
334
+ # `IntegerRange` carriers. We compare via the numeric
335
+ # `lower` / `upper` accessors (`-Float::INFINITY` /
336
+ # `Float::INFINITY` for the symbolic ends), then map
337
+ # back to the symbolic-bound representation
338
+ # `IntegerRange.new` expects. The disjoint-meet case
339
+ # cannot arise from sound member-wise projections in
340
+ # v0.0.4 but is guarded defensively to keep the
341
+ # carrier total.
342
+ def narrow_integer_ranges(ranges)
343
+ numeric_low = ranges.map(&:lower).max
344
+ numeric_high = ranges.map(&:upper).min
345
+ return Type::Combinator.bot if numeric_low > numeric_high
346
+
347
+ min = numeric_low == -Float::INFINITY ? Type::IntegerRange::NEG_INFINITY : numeric_low.to_i
348
+ max = numeric_high == Float::INFINITY ? Type::IntegerRange::POS_INFINITY : numeric_high.to_i
349
+ Type::Combinator.integer_range(min, max)
350
+ end
351
+
223
352
  def tuple_first(tuple, _method_name, args)
224
353
  return nil unless args.empty?
225
354
  return Type::Combinator.constant_of(nil) if tuple.elements.empty?
@@ -6,6 +6,7 @@ require_relative "method_dispatcher/shape_dispatch"
6
6
  require_relative "method_dispatcher/rbs_dispatch"
7
7
  require_relative "method_dispatcher/iterator_dispatch"
8
8
  require_relative "method_dispatcher/file_folding"
9
+ require_relative "method_dispatcher/kernel_dispatch"
9
10
 
10
11
  module Rigor
11
12
  module Inference
@@ -93,7 +94,8 @@ module Rigor
93
94
 
94
95
  ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
95
96
  ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
96
- FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
97
+ FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
98
+ KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
97
99
  end
98
100
 
99
101
  def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../type"
6
+ require_relative "../rbs_extended"
6
7
  require_relative "rbs_type_translator"
7
8
 
8
9
  module Rigor
@@ -59,10 +60,13 @@ module Rigor
59
60
  rbs_method = lookup_rbs_method(def_node)
60
61
  return types unless rbs_method
61
62
 
62
- method_types = rbs_method.method_types
63
- return types if method_types.empty?
64
-
65
- apply_rbs_overloads(types, slots, method_types)
63
+ apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
64
+ # `rigor:v1:param: <name> <refinement>` annotations
65
+ # tighten the bound type for matching slots. Applied
66
+ # after the RBS-overload pass so the override is the
67
+ # authoritative answer regardless of what the RBS
68
+ # signature declared.
69
+ apply_param_overrides(types, slots, rbs_method)
66
70
  types
67
71
  end
68
72
 
@@ -165,6 +169,27 @@ module Rigor
165
169
  end
166
170
  end
167
171
 
172
+ # Reads the override map off the method's annotations and
173
+ # replaces the binding for any slot whose name appears in
174
+ # the map. Anonymous slots are skipped (no name to match).
175
+ # The override is used verbatim — no `:rest_*` re-wrapping —
176
+ # so authors who tighten a `*rest` parameter to e.g.
177
+ # `non-empty-array[Integer]` describe the parameter binding
178
+ # they actually want, not its element type.
179
+ def apply_param_overrides(types, slots, rbs_method)
180
+ override_map = RbsExtended.param_type_override_map(rbs_method)
181
+ return if override_map.empty?
182
+
183
+ slots.each do |slot|
184
+ next if slot.name.nil?
185
+
186
+ override = override_map[slot.name]
187
+ next if override.nil?
188
+
189
+ types[slot.name] = override
190
+ end
191
+ end
192
+
168
193
  def collect_translated_types(method_types, slot)
169
194
  rbs_types = method_types.flat_map do |mt|
170
195
  t = rbs_type_for_slot(mt.type, slot)