rigortype 0.0.2 → 0.0.3

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/data/builtins/ruby_core/array.yml +1470 -0
  3. data/data/builtins/ruby_core/file.yml +501 -0
  4. data/data/builtins/ruby_core/io.yml +1594 -0
  5. data/data/builtins/ruby_core/numeric.yml +1809 -0
  6. data/data/builtins/ruby_core/string.yml +1850 -0
  7. data/lib/rigor/analysis/check_rules.rb +86 -1
  8. data/lib/rigor/analysis/runner.rb +4 -0
  9. data/lib/rigor/builtins/imported_refinements.rb +69 -0
  10. data/lib/rigor/configuration.rb +6 -1
  11. data/lib/rigor/inference/acceptance.rb +149 -0
  12. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  13. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  14. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  15. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  16. data/lib/rigor/inference/expression_typer.rb +48 -1
  17. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
  18. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  19. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
  20. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  21. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
  22. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  23. data/lib/rigor/inference/narrowing.rb +374 -4
  24. data/lib/rigor/inference/scope_indexer.rb +10 -2
  25. data/lib/rigor/inference/statement_evaluator.rb +211 -2
  26. data/lib/rigor/rbs_extended.rb +65 -1
  27. data/lib/rigor/scope.rb +14 -0
  28. data/lib/rigor/type/combinator.rb +69 -1
  29. data/lib/rigor/type/difference.rb +155 -0
  30. data/lib/rigor/type/integer_range.rb +137 -0
  31. data/lib/rigor/type.rb +2 -0
  32. data/lib/rigor/version.rb +1 -1
  33. data/sig/rigor/rbs_extended.rbs +3 -0
  34. data/sig/rigor/scope.rbs +1 -0
  35. data/sig/rigor/type.rbs +51 -1
  36. metadata +15 -1
@@ -1,19 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../type"
4
+ require_relative "../builtins/numeric_catalog"
5
+ require_relative "../builtins/string_catalog"
6
+ require_relative "../builtins/array_catalog"
4
7
 
5
8
  module Rigor
6
9
  module Inference
7
10
  module MethodDispatcher
8
- # Slice 2 rule book that folds binary operations on `Rigor::Type::Constant`
9
- # receivers into another `Constant` whenever:
11
+ # Slice 2 rule book that folds method calls on `Rigor::Type::Constant`
12
+ # receivers (and unions of them) into another `Constant` (or a small
13
+ # `Union[Constant, …]`) whenever:
10
14
  #
11
- # * the receiver is a recognised scalar literal,
12
- # * exactly one argument is supplied and it is also a `Constant`,
15
+ # * the receiver is a recognised scalar literal, OR a `Union` whose
16
+ # members are all `Constant`,
17
+ # * arguments (zero or one) are likewise `Constant` or `Union[Constant…]`,
13
18
  # * the method name is in the curated whitelist for the receiver's class,
14
19
  # * the operation cannot accidentally explode the analyzer (we cap
15
- # string-fold output at `STRING_FOLD_BYTE_LIMIT` bytes), and
16
- # * the actual Ruby invocation does not raise.
20
+ # string-fold output at `STRING_FOLD_BYTE_LIMIT` bytes, the input
21
+ # cartesian product at `UNION_FOLD_INPUT_LIMIT`, and the deduped
22
+ # output union at `UNION_FOLD_OUTPUT_LIMIT`), and
23
+ # * the actual Ruby invocation does not raise on at least one
24
+ # receiver/argument combination.
17
25
  #
18
26
  # Anything else returns `nil`, signalling "no rule matched" so the
19
27
  # caller (`ExpressionTyper`) falls back to `Dynamic[Top]` and records a
@@ -21,7 +29,7 @@ module Rigor
21
29
  # behind this rule book, but the constant-folding semantics defined
22
30
  # here MUST NOT regress: any value reachable by literal arithmetic at
23
31
  # parse time is meant to be foldable independent of RBS data.
24
- module ConstantFolding
32
+ module ConstantFolding # rubocop:disable Metrics/ModuleLength
25
33
  module_function
26
34
 
27
35
  NUMERIC_BINARY = Set[:+, :-, :*, :/, :%, :<, :<=, :>, :>=, :==, :!=, :<=>].freeze
@@ -30,25 +38,651 @@ module Rigor
30
38
  BOOL_BINARY = Set[:&, :|, :^, :==, :!=].freeze
31
39
  NIL_BINARY = Set[:==, :!=].freeze
32
40
 
41
+ # v0.0.3 C — pure unary catalogue. Each method must:
42
+ # - take zero arguments,
43
+ # - have no side effects,
44
+ # - never raise on the type's full domain (or be
45
+ # guarded by `safe?` below),
46
+ # - return a value safe to materialise as a
47
+ # `Constant` (no large strings, no host objects).
48
+ #
49
+ # The catalogue is the prerequisite for aggressive
50
+ # constant folding through user methods: once
51
+ # `Constant[3].odd?` folds to `Constant[true]`, the
52
+ # inter-procedural inference path landed in v0.0.2
53
+ # #5 carries the constant through the body of a
54
+ # user-defined `def is_odd(n) = n.odd?` so
55
+ # `Parity.new.is_odd(3)` types as `Constant[true]`
56
+ # rather than the RBS-widened `bool`.
57
+ INTEGER_UNARY = Set[
58
+ :odd?, :even?, :zero?, :positive?, :negative?,
59
+ :succ, :pred, :next, :abs, :magnitude,
60
+ :bit_length, :to_s, :to_i, :to_int, :to_f,
61
+ :inspect, :hash, :-@, :+@, :~
62
+ ].freeze
63
+ FLOAT_UNARY = Set[
64
+ :zero?, :positive?, :negative?,
65
+ :nan?, :finite?, :infinite?,
66
+ :abs, :magnitude, :floor, :ceil, :round, :truncate,
67
+ :to_s, :to_i, :to_int, :to_f,
68
+ :inspect, :hash, :-@, :+@
69
+ ].freeze
70
+ STRING_UNARY = Set[
71
+ :upcase, :downcase, :capitalize, :swapcase,
72
+ :reverse, :length, :size, :bytesize,
73
+ :empty?, :strip, :lstrip, :rstrip, :chomp,
74
+ :to_s, :to_str, :to_sym, :intern,
75
+ :inspect, :hash
76
+ ].freeze
77
+ SYMBOL_UNARY = Set[
78
+ :to_s, :to_sym, :to_proc, :length, :size,
79
+ :empty?, :upcase, :downcase, :capitalize,
80
+ :swapcase, :inspect, :hash
81
+ ].freeze
82
+ BOOL_UNARY = Set[:!, :to_s, :inspect, :hash, :&, :|, :^].freeze
83
+ NIL_UNARY = Set[:nil?, :!, :to_s, :to_a, :to_h, :inspect, :hash].freeze
84
+
33
85
  STRING_FOLD_BYTE_LIMIT = 4096
34
86
 
35
- # @return [Rigor::Type::Constant, nil]
87
+ # Input cartesian product hard cap. Keeps fold cost bounded even
88
+ # when the receiver and argument are both `Union[Constant…]`.
89
+ # 5 × 5 = 25 inputs is permitted; 6 × 6 = 36 is not. The user-
90
+ # facing payoff (a precise small enum) drops off fast past this
91
+ # range and CRuby method invocation cost adds up.
92
+ UNION_FOLD_INPUT_LIMIT = 32
93
+
94
+ # Output cardinality cap on the deduped result union. A single
95
+ # binary op on a small range can collapse: `[1,2,3] + [2,4,6]`
96
+ # produces 9 raw pairs but only 7 distinct sums. The output cap
97
+ # is what ultimately limits how wide an inferred type gets.
98
+ UNION_FOLD_OUTPUT_LIMIT = 8
99
+
100
+ # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
36
101
  def try_fold(receiver:, method_name:, args:)
37
- return nil unless receiver.is_a?(Type::Constant)
38
- return nil if args.size != 1
102
+ receiver_set = numeric_set_of(receiver)
103
+ return nil unless receiver_set
104
+
105
+ arg_sets = args.map { |a| numeric_set_of(a) }
106
+ return nil if arg_sets.any?(&:nil?)
107
+
108
+ case args.size
109
+ when 0 then try_fold_unary(receiver_set, method_name)
110
+ when 1 then try_fold_binary(receiver_set, method_name, arg_sets.first)
111
+ end
112
+ end
113
+
114
+ # Normalises an input type into one of:
115
+ # - `Array<Object>` for a `Constant` (1-element) or
116
+ # `Union[Constant…]` (n-element) — concrete values to enumerate.
117
+ # - `Type::IntegerRange` — bounded interval.
118
+ # - `nil` — the input shape is not foldable.
119
+ def numeric_set_of(type)
120
+ case type
121
+ when Type::Constant then [type.value]
122
+ when Type::Union
123
+ return nil unless type.members.all?(Type::Constant)
124
+
125
+ type.members.map(&:value)
126
+ when Type::IntegerRange then type
127
+ end
128
+ end
129
+
130
+ def try_fold_unary(set, method_name)
131
+ case set
132
+ when Array then try_fold_unary_set(set, method_name)
133
+ when Type::IntegerRange then try_fold_unary_range(set, method_name)
134
+ end
135
+ end
136
+
137
+ def try_fold_binary(left, method_name, right)
138
+ return try_fold_divmod(left, right) if method_name == :divmod
139
+
140
+ if left.is_a?(Type::IntegerRange) || right.is_a?(Type::IntegerRange)
141
+ try_fold_binary_range(left, method_name, right)
142
+ else
143
+ try_fold_binary_set(left, method_name, right)
144
+ end
145
+ end
146
+
147
+ # `Integer#divmod` and `Float#divmod` return a 2-element array
148
+ # `[quotient, remainder]`. We project that into
149
+ # `Tuple[Constant[q], Constant[r]]` so downstream rules see
150
+ # the precise element types. Union/range receivers are
151
+ # widened per-position: each tuple slot carries the union of
152
+ # quotients (resp. remainders) over every safe input pair.
153
+ # Range inputs are not yet folded — they bail to nil.
154
+ def try_fold_divmod(left, right)
155
+ pairs = collect_divmod_pairs(left, right)
156
+ return nil unless pairs && !pairs.empty?
157
+
158
+ q_type = build_constant_type(pairs.map(&:first))
159
+ r_type = build_constant_type(pairs.map(&:last))
160
+ return nil unless q_type && r_type
161
+
162
+ Type::Combinator.tuple_of(q_type, r_type)
163
+ end
164
+
165
+ def collect_divmod_pairs(left, right)
166
+ return nil unless left.is_a?(Array) && right.is_a?(Array)
167
+ return nil if left.size * right.size > UNION_FOLD_INPUT_LIMIT
168
+
169
+ left.flat_map do |lv|
170
+ right.flat_map { |rv| invoke_divmod(lv, rv) || [] }
171
+ end
172
+ end
173
+
174
+ # Returns `[[quotient, remainder]]` (single-element array
175
+ # wrapping the tuple) on success; `nil` to signal "skip this
176
+ # pair". The wrapping mirrors `invoke_binary` so we can
177
+ # use `flat_map` and not lose legitimate 0/false elements.
178
+ def invoke_divmod(receiver_value, arg_value)
179
+ return nil unless receiver_value.is_a?(Numeric) && arg_value.is_a?(Numeric)
180
+
181
+ result = receiver_value.divmod(arg_value)
182
+ divmod_result_to_pair(result)
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def divmod_result_to_pair(result)
188
+ return nil unless result.is_a?(Array) && result.size == 2
189
+ return nil unless result.all? { |v| foldable_constant_value?(v) }
190
+
191
+ [result]
192
+ end
193
+
194
+ def try_fold_unary_set(receiver_values, method_name)
195
+ # Type-level allow check on every receiver. If one member's
196
+ # type does not have the method in its allow list (e.g.
197
+ # `Union[String, nil].nil?` — `:nil?` is not in
198
+ # `STRING_UNARY`), bail the fold so the RBS tier answers.
199
+ # Silently dropping the unsafe member would lie about the
200
+ # remaining receivers' behaviour.
201
+ return nil unless receiver_values.all? { |rv| unary_method_allowed?(rv, method_name) }
202
+
203
+ results = receiver_values.flat_map do |rv|
204
+ invoke_unary(rv, method_name) || []
205
+ end
206
+ build_constant_type(results, source: receiver_values)
207
+ end
208
+
209
+ def try_fold_binary_set(receiver_values, method_name, arg_values)
210
+ return nil if receiver_values.size * arg_values.size > UNION_FOLD_INPUT_LIMIT
211
+ return nil unless receiver_values.all? { |rv| binary_method_allowed?(rv, method_name) }
212
+
213
+ results = receiver_values.flat_map do |rv|
214
+ arg_values.flat_map { |av| invoke_binary(rv, method_name, av) || [] }
215
+ end
216
+ build_constant_type(results, source: receiver_values + arg_values)
217
+ end
218
+
219
+ def unary_method_allowed?(receiver_value, method_name)
220
+ unary_ops_for(receiver_value).include?(method_name) ||
221
+ catalog_allows?(receiver_value, method_name)
222
+ end
223
+
224
+ def binary_method_allowed?(receiver_value, method_name)
225
+ ops_for(receiver_value).include?(method_name) ||
226
+ catalog_allows?(receiver_value, method_name)
227
+ end
228
+
229
+ # Builds a Constant or Union[Constant…] from a flat list of
230
+ # Ruby values. When the deduped set exceeds
231
+ # `UNION_FOLD_OUTPUT_LIMIT` and every result is an Integer,
232
+ # widens to the bounding `IntegerRange` instead of returning
233
+ # nil — that is the graceful escape valve for additions over
234
+ # disjoint integer ranges. The `source` array is used only as
235
+ # a hint that the result set's "Integer-ness" was already
236
+ # implied by the inputs (so the widening fallback only fires
237
+ # for arithmetic over integers).
238
+ def build_constant_type(values, source: nil)
239
+ return nil if values.empty?
240
+
241
+ unique = values.uniq
242
+ return collapse_constants(unique) if unique.size <= UNION_FOLD_OUTPUT_LIMIT
243
+
244
+ widen_to_integer_range(unique, source) ||
245
+ (raise_if_strict || nil)
246
+ end
247
+
248
+ def collapse_constants(values)
249
+ constants = values.map { |v| Type::Combinator.constant_of(v) }
250
+ constants.size == 1 ? constants.first : Type::Combinator.union(*constants)
251
+ end
252
+
253
+ # Widening fallback: when every successful result is an
254
+ # Integer, return the bounding `IntegerRange` rather than
255
+ # losing the answer entirely. The fallback is also gated on
256
+ # the input set being all-integers, so a fold whose results
257
+ # happen to land on integers but whose receivers were Floats
258
+ # does not silently change shape.
259
+ def widen_to_integer_range(values, source)
260
+ return nil unless values.all?(Integer)
261
+ return nil if source && !source.all? { |v| v.is_a?(Integer) || v.is_a?(Type::IntegerRange) }
262
+
263
+ Type::Combinator.integer_range(values.min, values.max)
264
+ end
265
+
266
+ # Reserved hook: present so future `:strict` modes can raise
267
+ # rather than silently returning nil. Today it always returns
268
+ # nil so behaviour is unchanged.
269
+ def raise_if_strict
270
+ nil
271
+ end
272
+
273
+ # ----------------------------------------------------------------
274
+ # IntegerRange arithmetic and comparison.
275
+ # ----------------------------------------------------------------
276
+
277
+ RANGE_ADDITIVE = Set[:+, :-].freeze
278
+ RANGE_COMPARISON = Set[:<, :<=, :>, :>=, :==, :!=].freeze
279
+
280
+ # Per-operator dispatch table for binary range ops. Each
281
+ # value is a method symbol on `ConstantFolding` taking
282
+ # `(left, right)` and returning a `Type` or `nil`.
283
+ BINARY_RANGE_HANDLERS = {
284
+ :* => :range_multiply,
285
+ :/ => :range_divide,
286
+ :% => :range_modulo
287
+ }.freeze
288
+ private_constant :BINARY_RANGE_HANDLERS
289
+
290
+ def try_fold_binary_range(left, method_name, right)
291
+ l = ensure_integer_range(left)
292
+ r = ensure_integer_range(right)
293
+ return nil unless l && r
294
+ return range_additive(l, method_name, r) if RANGE_ADDITIVE.include?(method_name)
295
+ return range_comparison(l, method_name, r) if RANGE_COMPARISON.include?(method_name)
296
+ return send(BINARY_RANGE_HANDLERS[method_name], l, r) if BINARY_RANGE_HANDLERS.key?(method_name)
297
+
298
+ nil
299
+ end
300
+
301
+ # Promotes an array-of-values input to an `IntegerRange` when
302
+ # every value is an `Integer`. Used so a mixed `Constant +
303
+ # IntegerRange` call can be reduced to range × range
304
+ # arithmetic. Returns `nil` for non-Integer arrays so a
305
+ # `Constant[Float]` does not silently degrade.
306
+ def ensure_integer_range(operand)
307
+ case operand
308
+ when Type::IntegerRange then operand
309
+ when Array
310
+ return nil unless operand.all?(Integer)
311
+
312
+ Type::Combinator.integer_range(operand.min, operand.max)
313
+ end
314
+ end
315
+
316
+ def range_additive(left, method_name, right)
317
+ lower, upper =
318
+ case method_name
319
+ when :+ then [left.lower + right.lower, left.upper + right.upper]
320
+ when :- then [left.lower - right.upper, left.upper - right.lower]
321
+ end
322
+ build_integer_range(lower, upper)
323
+ end
324
+
325
+ # Range × Range. Computes the four corner products with
326
+ # `safe_mul` so that `0 × ±∞` is treated as 0 rather than
327
+ # NaN — that captures the algebraic truth that the actual
328
+ # range elements are integers, never literal infinity.
329
+ def range_multiply(left, right)
330
+ corners = [
331
+ safe_mul(left.lower, right.lower),
332
+ safe_mul(left.lower, right.upper),
333
+ safe_mul(left.upper, right.lower),
334
+ safe_mul(left.upper, right.upper)
335
+ ]
336
+ build_integer_range(corners.min, corners.max)
337
+ end
338
+
339
+ # 0 dominates: 0 × anything (including ±∞) is 0. Without this
340
+ # special case Ruby's `0 * Float::INFINITY` is `NaN`, which
341
+ # would corrupt `min`/`max`.
342
+ def safe_mul(left, right)
343
+ return 0 if left.zero? || right.zero?
344
+
345
+ left * right
346
+ end
347
+
348
+ # Range ÷ Range using Ruby's integer floor division. If the
349
+ # right range covers 0 the operation may raise
350
+ # `ZeroDivisionError`, so the fold bails (caller falls back
351
+ # to RBS-widened `Integer`). When both inputs are finite we
352
+ # compute the four corner quotients; the universal-on-one-side
353
+ # case is handled by treating ±∞ ÷ n as ±∞ and n ÷ ±∞ as 0.
354
+ def range_divide(left, right)
355
+ return nil if right.covers?(0)
356
+
357
+ corners = [
358
+ safe_div(left.lower, right.lower),
359
+ safe_div(left.lower, right.upper),
360
+ safe_div(left.upper, right.lower),
361
+ safe_div(left.upper, right.upper)
362
+ ]
363
+ build_integer_range(corners.min, corners.max)
364
+ end
365
+
366
+ def safe_div(numer, denom)
367
+ return 0 if numer.zero?
368
+ return numer.positive? ^ denom.negative? ? Float::INFINITY : -Float::INFINITY if denom.zero?
369
+ return 0 if denom.infinite?
370
+
371
+ if numer.infinite?
372
+ return numer.positive? ^ denom.negative? ? Float::INFINITY : -Float::INFINITY
373
+ end
374
+
375
+ numer.to_i.div(denom.to_i).to_f
376
+ end
377
+
378
+ # Range % Range. Only the `(any range) % (positive constant n)`
379
+ # and `(any range) % (negative constant n)` cases are folded
380
+ # precisely — the former narrows to `int<0, n-1>`, the latter
381
+ # to `int<n+1, 0>`. Other shapes fall back to nil.
382
+ def range_modulo(_left, right)
383
+ return nil unless right.finite? && right.min == right.max
384
+
385
+ divisor = right.min
386
+ return nil if divisor.zero?
387
+
388
+ if divisor.positive?
389
+ build_integer_range(0, divisor - 1)
390
+ else
391
+ build_integer_range(divisor + 1, 0)
392
+ end
393
+ end
394
+
395
+ # Builds an `IntegerRange` from numeric `lower`/`upper`
396
+ # endpoints. Collapses single-point finite ranges to a
397
+ # `Constant` so downstream rules (which prefer the more
398
+ # specific carrier) see the most precise result.
399
+ def build_integer_range(lower, upper)
400
+ min = lower == -Float::INFINITY ? Type::IntegerRange::NEG_INFINITY : Integer(lower)
401
+ max = upper == Float::INFINITY ? Type::IntegerRange::POS_INFINITY : Integer(upper)
402
+ if min.is_a?(Integer) && max.is_a?(Integer) && min == max
403
+ Type::Combinator.constant_of(min)
404
+ else
405
+ Type::Combinator.integer_range(min, max)
406
+ end
407
+ end
408
+
409
+ def range_comparison(left, method_name, right)
410
+ decision = decide_range_comparison(left, method_name, right)
411
+ case decision
412
+ when :always_true then Type::Combinator.constant_of(true)
413
+ when :always_false then Type::Combinator.constant_of(false)
414
+ when :both then bool_union
415
+ end
416
+ end
417
+
418
+ def bool_union
419
+ Type::Combinator.union(
420
+ Type::Combinator.constant_of(true),
421
+ Type::Combinator.constant_of(false)
422
+ )
423
+ end
424
+
425
+ BOOL_INVERSE = {
426
+ always_true: :always_false,
427
+ always_false: :always_true,
428
+ both: :both
429
+ }.freeze
430
+ private_constant :BOOL_INVERSE
431
+
432
+ def decide_range_comparison(left, method_name, right)
433
+ case method_name
434
+ when :< then decide_lt(left, right)
435
+ when :<= then decide_le(left, right)
436
+ when :> then decide_lt(right, left)
437
+ when :>= then decide_le(right, left)
438
+ when :== then decide_eq(left, right)
439
+ when :!= then BOOL_INVERSE[decide_eq(left, right)]
440
+ end
441
+ end
442
+
443
+ def decide_lt(left, right)
444
+ return :always_true if left.upper < right.lower
445
+ return :always_false if left.lower >= right.upper
446
+
447
+ :both
448
+ end
449
+
450
+ def decide_le(left, right)
451
+ return :always_true if left.upper <= right.lower
452
+ return :always_false if left.lower > right.upper
453
+
454
+ :both
455
+ end
456
+
457
+ def decide_eq(left, right)
458
+ return :always_true if left.finite? && right.finite? && left.min == left.max && left == right
459
+ return :always_false if left.upper < right.lower || right.upper < left.lower
39
460
 
40
- arg = args.first
41
- return nil unless arg.is_a?(Type::Constant)
42
- return nil unless safe?(receiver.value, method_name, arg.value)
461
+ :both
462
+ end
463
+
464
+ # ----------------------------------------------------------------
465
+ # IntegerRange unary folds.
466
+ # ----------------------------------------------------------------
467
+
468
+ RANGE_UNARY_PREDICATES = Set[:zero?, :positive?, :negative?].freeze
469
+ RANGE_UNARY_SHIFTS = Set[:succ, :next, :pred].freeze
470
+ RANGE_UNARY_PARITY = Set[:even?, :odd?].freeze
471
+
472
+ # `(method_name) -> handler symbol` for the unary range
473
+ # surface that does not need extra context. The grouped
474
+ # categories (predicates / shifts / parity) stay separate
475
+ # because they share dispatch logic.
476
+ UNARY_RANGE_DIRECT = {
477
+ abs: :range_unary_abs,
478
+ magnitude: :range_unary_abs,
479
+ "-@": :range_unary_negate,
480
+ "+@": :range_unary_identity,
481
+ bit_length: :range_unary_bit_length
482
+ }.freeze
483
+ private_constant :UNARY_RANGE_DIRECT
484
+
485
+ def try_fold_unary_range(range, method_name)
486
+ return range_unary_predicate(range, method_name) if RANGE_UNARY_PREDICATES.include?(method_name)
487
+ return range_unary_shift(range, method_name) if RANGE_UNARY_SHIFTS.include?(method_name)
488
+ return range_unary_parity(range, method_name) if RANGE_UNARY_PARITY.include?(method_name)
489
+ return send(UNARY_RANGE_DIRECT[method_name], range) if UNARY_RANGE_DIRECT.key?(method_name)
490
+
491
+ nil
492
+ end
493
+
494
+ def range_unary_negate(range)
495
+ build_integer_range(-range.upper, -range.lower)
496
+ end
497
+
498
+ def range_unary_identity(range)
499
+ range
500
+ end
501
+
502
+ # `even?`/`odd?` on a single-point range collapses to an
503
+ # exact `Constant[bool]`. Any range spanning ≥ 2 integers
504
+ # contains both an even and an odd value, so the result is
505
+ # `Union[true, false]`.
506
+ def range_unary_parity(range, method_name)
507
+ if range.finite? && range.min == range.max
508
+ value = range.min.public_send(method_name)
509
+ Type::Combinator.constant_of(value)
510
+ else
511
+ bool_union
512
+ end
513
+ end
514
+
515
+ # Integer#bit_length is non-negative and bounded by the
516
+ # bit_length of the wider endpoint. For half-open ranges the
517
+ # upper bound is unknown (any large integer is reachable), so
518
+ # we widen to non_negative_int. Negative endpoints map via
519
+ # `~n` semantics; using the magnitude is a safe upper bound.
520
+ def range_unary_bit_length(range)
521
+ return Type::Combinator.non_negative_int unless range.finite?
43
522
 
44
- Type::Combinator.constant_of(receiver.value.public_send(method_name, arg.value))
523
+ width = [range.min.bit_length, range.max.bit_length].max
524
+ build_integer_range(0, width)
525
+ end
526
+
527
+ def range_unary_predicate(range, method_name)
528
+ decision =
529
+ case method_name
530
+ when :zero? then decide_zero(range)
531
+ when :positive? then decide_positive(range)
532
+ when :negative? then decide_negative(range)
533
+ end
534
+ range_comparison_result(decision)
535
+ end
536
+
537
+ def range_comparison_result(decision)
538
+ case decision
539
+ when :always_true then Type::Combinator.constant_of(true)
540
+ when :always_false then Type::Combinator.constant_of(false)
541
+ when :both then bool_union
542
+ end
543
+ end
544
+
545
+ def decide_zero(range)
546
+ return :always_true if range.finite? && range.min.zero? && range.max.zero?
547
+ return :always_false unless range.covers?(0)
548
+
549
+ :both
550
+ end
551
+
552
+ def decide_positive(range)
553
+ return :always_true if range.lower.positive?
554
+ return :always_false if range.upper <= 0
555
+
556
+ :both
557
+ end
558
+
559
+ def decide_negative(range)
560
+ return :always_true if range.upper.negative?
561
+ return :always_false if range.lower >= 0
562
+
563
+ :both
564
+ end
565
+
566
+ def range_unary_shift(range, method_name)
567
+ delta = method_name == :pred ? -1 : 1
568
+ build_integer_range(range.lower + delta, range.upper + delta)
569
+ end
570
+
571
+ def range_unary_abs(range)
572
+ if range.lower >= 0
573
+ range
574
+ elsif range.upper <= 0
575
+ build_integer_range(-range.upper, -range.lower)
576
+ else
577
+ magnitude = [range.lower.abs, range.upper.abs].max
578
+ build_integer_range(0, magnitude)
579
+ end
580
+ end
581
+
582
+ # ----------------------------------------------------------------
583
+
584
+ # Returns `[value]` on success, `nil` to signal "skip this pair".
585
+ # The 1-element-array shape lets callers distinguish a successful
586
+ # `false`/`nil` fold from a skipped pair when chaining via
587
+ # `flat_map`.
588
+ def invoke_binary(receiver_value, method_name, arg_value)
589
+ return nil unless safe?(receiver_value, method_name, arg_value)
590
+
591
+ result = receiver_value.public_send(method_name, arg_value)
592
+ foldable_constant_value?(result) ? [result] : nil
593
+ rescue StandardError
594
+ nil
595
+ end
596
+
597
+ # Returns `[value]` on success, `nil` to signal "skip". See
598
+ # `invoke_binary` for why we wrap.
599
+ def invoke_unary(receiver_value, method_name)
600
+ return nil unless unary_safe?(receiver_value, method_name)
601
+ return nil if string_unary_blow_up?(receiver_value, method_name)
602
+
603
+ result = receiver_value.public_send(method_name)
604
+ foldable_constant_value?(result) ? [result] : nil
45
605
  rescue StandardError
46
606
  nil
47
607
  end
48
608
 
609
+ def unary_safe?(receiver_value, method_name)
610
+ return true if unary_ops_for(receiver_value).include?(method_name)
611
+
612
+ catalog_allows?(receiver_value, method_name)
613
+ end
614
+
615
+ # Consults the offline numeric catalog (data/builtins/ruby_core/
616
+ # numeric.yml) as a superset of the hand-rolled unary/binary
617
+ # allow lists. The catalog's `leaf` / `trivial` /
618
+ # `leaf_when_numeric` entries promise the underlying CRuby
619
+ # implementation does not call back into user-redefinable
620
+ # Ruby methods, so executing them on a literal Integer/Float
621
+ # is safe regardless of monkey-patching.
622
+ def catalog_allows?(receiver_value, method_name)
623
+ catalog, class_name = catalog_for(receiver_value)
624
+ return false unless catalog
625
+
626
+ catalog.safe_for_folding?(class_name, method_name)
627
+ end
628
+
629
+ # Returns `[catalog, class_name]` for receivers we have a
630
+ # catalog for; nil otherwise. The class_name is what the
631
+ # catalog's RBS-rooted entries are keyed by.
632
+ def catalog_for(receiver_value)
633
+ case receiver_value
634
+ when Integer then [Builtins::NumericCatalog, "Integer"]
635
+ when Float then [Builtins::NumericCatalog, "Float"]
636
+ when String then [Builtins::STRING_CATALOG, "String"]
637
+ when Symbol then [Builtins::STRING_CATALOG, "Symbol"]
638
+ when Array then [Builtins::ARRAY_CATALOG, "Array"]
639
+ end
640
+ end
641
+
642
+ def unary_ops_for(receiver_value)
643
+ case receiver_value
644
+ when Integer then INTEGER_UNARY
645
+ when Float then FLOAT_UNARY
646
+ when String then STRING_UNARY
647
+ when Symbol then SYMBOL_UNARY
648
+ when true, false then BOOL_UNARY
649
+ when nil then NIL_UNARY
650
+ else Set.new
651
+ end
652
+ end
653
+
654
+ # `String#reverse` / `#swapcase` etc. produce a
655
+ # string the same size as the receiver; only the
656
+ # already-handled binary `:+` / `:*` paths can
657
+ # explode the output. No unary string method
658
+ # currently in the catalogue grows beyond the input
659
+ # size, so this hook is a no-op today — kept as a
660
+ # placeholder so future additions (e.g. `:succ` on
661
+ # very long strings) can be guarded without
662
+ # restructuring.
663
+ def string_unary_blow_up?(_receiver_value, _method_name)
664
+ false
665
+ end
666
+
667
+ # Scalar / String / Symbol values fold; everything
668
+ # else (Array, Hash, Proc, Range, ...) is held back
669
+ # because `Type::Constant` does not model those
670
+ # carriers and surfacing one would mis-type
671
+ # downstream calls. `Range`, `Array`, and friends
672
+ # have their own shape carriers; this method picks
673
+ # the conservative envelope of "values that already
674
+ # round-trip through `Type::Combinator.constant_of`".
675
+ def foldable_constant_value?(value)
676
+ case value
677
+ when Integer, Float, String, Symbol, true, false, nil then true
678
+ else false
679
+ end
680
+ end
681
+
49
682
  def safe?(receiver_value, method_name, arg_value)
50
- ops = ops_for(receiver_value)
51
- return false unless ops.include?(method_name)
683
+ allowed = ops_for(receiver_value).include?(method_name) ||
684
+ catalog_allows?(receiver_value, method_name)
685
+ return false unless allowed
52
686
  return false if integer_division_by_zero?(receiver_value, method_name, arg_value)
53
687
  return false if string_blow_up?(receiver_value, method_name, arg_value)
54
688