rigortype 0.1.8 → 0.1.9

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/cli/annotate_command.rb +224 -0
  5. data/lib/rigor/cli/baseline_command.rb +36 -16
  6. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  7. data/lib/rigor/cli.rb +62 -4
  8. data/lib/rigor/environment.rb +9 -1
  9. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  10. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  11. data/lib/rigor/inference/expression_typer.rb +165 -6
  12. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  13. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  14. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  15. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  16. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  17. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  18. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  19. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  20. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  21. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  22. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  23. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  24. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  25. data/lib/rigor/inference/narrowing.rb +29 -10
  26. data/lib/rigor/inference/statement_evaluator.rb +3 -1
  27. data/lib/rigor/plugin/base.rb +39 -0
  28. data/lib/rigor/plugin/loader.rb +22 -1
  29. data/lib/rigor/plugin/manifest.rb +73 -10
  30. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  31. data/lib/rigor/plugin/registry.rb +66 -0
  32. data/lib/rigor/triage/catalogue.rb +2 -2
  33. data/lib/rigor/type/constant.rb +29 -2
  34. data/lib/rigor/version.rb +1 -1
  35. metadata +11 -1
@@ -1056,6 +1056,9 @@ module Rigor
1056
1056
  per_element = try_per_element_block_fold(node, receiver)
1057
1057
  return per_element if per_element
1058
1058
 
1059
+ hash_transform = try_hash_shape_block_fold(node, receiver)
1060
+ return hash_transform if hash_transform
1061
+
1059
1062
  result = MethodDispatcher.dispatch(
1060
1063
  receiver_type: receiver,
1061
1064
  method_name: node.name,
@@ -1455,10 +1458,17 @@ module Rigor
1455
1458
  # the dispatch chain untouched.
1456
1459
  PER_ELEMENT_TUPLE_METHODS = Set[
1457
1460
  :map, :collect, :filter_map, :flat_map,
1461
+ :select, :filter, :reject,
1458
1462
  :find, :detect, :find_index, :index
1459
1463
  ].freeze
1460
1464
  private_constant :PER_ELEMENT_TUPLE_METHODS
1461
1465
 
1466
+ HASH_SHAPE_TRANSFORM_METHODS = Set[
1467
+ :transform_keys, :transform_keys!,
1468
+ :transform_values, :transform_values!
1469
+ ].freeze
1470
+ private_constant :HASH_SHAPE_TRANSFORM_METHODS
1471
+
1462
1472
  # Cardinality cap for per-element block fold over
1463
1473
  # finite-bound `Constant<Range>` receivers. Walking
1464
1474
  # `(1..1_000_000).map { … }` element-wise would balloon
@@ -1475,15 +1485,50 @@ module Rigor
1475
1485
  element_types = per_element_elements_of(receiver_type)
1476
1486
  return nil if element_types.nil? || element_types.empty?
1477
1487
 
1478
- block_node = call_node.block
1479
- return nil unless block_node.is_a?(Prism::BlockNode)
1488
+ per_position = per_element_block_results(call_node.block, element_types)
1489
+ return nil if per_position.nil? || per_position.any?(&:nil?)
1480
1490
 
1481
- per_position = element_types.map do |element_type|
1482
- type_block_body_with_param(block_node, [element_type])
1491
+ assemble_per_element_result(call_node.name, per_position, element_types)
1492
+ end
1493
+
1494
+ # Evaluates the call's block once per receiver element.
1495
+ # Two block shapes are supported:
1496
+ #
1497
+ # - `Prism::BlockNode` — a full `do … end` / `{ … }` block;
1498
+ # the body is re-typed per position with the element
1499
+ # bound to the block parameter.
1500
+ # - `Prism::BlockArgumentNode` wrapping a `SymbolNode` —
1501
+ # the `&:predicate` shorthand; the symbol is dispatched
1502
+ # as a zero-arg method on each element type.
1503
+ #
1504
+ # Any other shape (`&proc_local`, `&method(:foo)`, no
1505
+ # block) returns `nil` so the fold declines.
1506
+ def per_element_block_results(block, element_types)
1507
+ case block
1508
+ when Prism::BlockNode
1509
+ element_types.map { |element_type| type_block_body_with_param(block, [element_type]) }
1510
+ when Prism::BlockArgumentNode
1511
+ per_element_symbol_results(block, element_types)
1483
1512
  end
1484
- return nil if per_position.any?(&:nil?)
1513
+ end
1485
1514
 
1486
- assemble_per_element_result(call_node.name, per_position, element_types)
1515
+ def per_element_symbol_results(block_arg, element_types)
1516
+ expression = block_arg.expression
1517
+ return nil unless expression.is_a?(Prism::SymbolNode)
1518
+
1519
+ method_name = expression.unescaped.to_sym
1520
+ element_types.map do |element_type|
1521
+ MethodDispatcher.dispatch(
1522
+ receiver_type: element_type,
1523
+ method_name: method_name,
1524
+ arg_types: [],
1525
+ block_type: nil,
1526
+ environment: scope.environment,
1527
+ scope: scope
1528
+ )
1529
+ end
1530
+ rescue StandardError
1531
+ nil
1487
1532
  end
1488
1533
 
1489
1534
  # Returns the per-position element types for a finite,
@@ -1532,11 +1577,37 @@ module Rigor
1532
1577
  when :map, :collect then Type::Combinator.tuple_of(*per_position)
1533
1578
  when :filter_map then assemble_filter_map_result(per_position)
1534
1579
  when :flat_map then assemble_flat_map_result(per_position)
1580
+ when :select, :filter
1581
+ assemble_filter_result(per_position, element_types, keep_on_truthy: true)
1582
+ when :reject
1583
+ assemble_filter_result(per_position, element_types, keep_on_truthy: false)
1535
1584
  when :find, :detect then assemble_find_result(per_position, element_types)
1536
1585
  when :find_index, :index then assemble_find_index_result(per_position)
1537
1586
  end
1538
1587
  end
1539
1588
 
1589
+ # `select` / `filter` / `reject`: keeps each receiver
1590
+ # element whose per-position predicate result folds to a
1591
+ # decisive `Constant` — Ruby-truthy for `select` / `filter`,
1592
+ # Ruby-falsey for `reject`. The surviving elements assemble
1593
+ # into a `Tuple`, strictly tighter than the RBS-projected
1594
+ # `Array[Elem]`.
1595
+ #
1596
+ # Folds tightly only when EVERY position is a `Constant`:
1597
+ # a single non-`Constant` position leaves the result
1598
+ # cardinality unknown (the element might or might not
1599
+ # survive), so the dispatcher declines and the RBS tier
1600
+ # widens to `Array[Elem]`. `[].select` style empty results
1601
+ # are sound — an empty `Tuple` is the empty-array carrier.
1602
+ def assemble_filter_result(per_position, element_types, keep_on_truthy:)
1603
+ return nil unless per_position.all?(Type::Constant)
1604
+
1605
+ kept = element_types.each_index.filter_map do |index|
1606
+ element_types[index] if truthy_constant?(per_position[index]) == keep_on_truthy
1607
+ end
1608
+ Type::Combinator.tuple_of(*kept)
1609
+ end
1610
+
1540
1611
  # `filter_map` folds tightly only when every per-position
1541
1612
  # result is a `Constant`: positions whose value is `nil`
1542
1613
  # or `false` drop, the rest survive in declaration order.
@@ -1611,6 +1682,94 @@ module Rigor
1611
1682
  type.is_a?(Type::Constant) && type.value && type.value != false
1612
1683
  end
1613
1684
 
1685
+ # Per-pair block fold for `HashShape#transform_keys` and
1686
+ # `HashShape#transform_values` (and their bang variants).
1687
+ #
1688
+ # When the receiver is a closed `HashShape` with no optional
1689
+ # keys, applies the call's block (a `Prism::BlockNode` or
1690
+ # `Prism::BlockArgumentNode`) to each key/value pair
1691
+ # independently and assembles a new `HashShape`:
1692
+ #
1693
+ # - `transform_values` / `transform_values!`: re-types
1694
+ # each VALUE by binding it to the block parameter; keys
1695
+ # are preserved unchanged.
1696
+ # - `transform_keys` / `transform_keys!`: re-types each
1697
+ # KEY by wrapping it in `Constant[k]` and passing it to
1698
+ # the block; values are preserved unchanged. The result
1699
+ # key must be a `Constant[Symbol | String]` — otherwise
1700
+ # the tier declines (the new key cannot be used as a
1701
+ # static HashShape index). Collisions (two old keys
1702
+ # mapping to the same new key) also decline.
1703
+ #
1704
+ # Returns `nil` on any decline so the dispatcher falls
1705
+ # through to `RbsDispatch` and gets the widened `Hash[K, V]`
1706
+ # answer.
1707
+ def try_hash_shape_block_fold(call_node, receiver_type)
1708
+ return nil unless HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name)
1709
+ return nil unless receiver_type.is_a?(Type::HashShape)
1710
+ return nil unless receiver_type.closed?
1711
+ return nil unless receiver_type.optional_keys.empty?
1712
+
1713
+ block_arg = call_node.block
1714
+ return nil if block_arg.nil?
1715
+
1716
+ if %i[transform_values transform_values!].include?(call_node.name)
1717
+ fold_hash_shape_transform_values(receiver_type, block_arg)
1718
+ else
1719
+ fold_hash_shape_transform_keys(receiver_type, block_arg)
1720
+ end
1721
+ end
1722
+
1723
+ def fold_hash_shape_transform_values(shape, block_arg)
1724
+ new_pairs = {}
1725
+ shape.pairs.each do |key, value|
1726
+ new_value = apply_hash_block(block_arg, value)
1727
+ return nil if new_value.nil?
1728
+
1729
+ new_pairs[key] = new_value
1730
+ end
1731
+ Type::Combinator.hash_shape_of(new_pairs)
1732
+ end
1733
+
1734
+ def fold_hash_shape_transform_keys(shape, block_arg)
1735
+ new_pairs = {}
1736
+ shape.pairs.each do |key, value|
1737
+ key_type = Type::Combinator.constant_of(key)
1738
+ new_key_type = apply_hash_block(block_arg, key_type)
1739
+ return nil unless new_key_type.is_a?(Type::Constant)
1740
+
1741
+ new_key = new_key_type.value
1742
+ return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
1743
+ return nil if new_pairs.key?(new_key)
1744
+
1745
+ new_pairs[new_key] = value
1746
+ end
1747
+ Type::Combinator.hash_shape_of(new_pairs)
1748
+ end
1749
+
1750
+ # Applies a single-argument block (either a full BlockNode
1751
+ # or a `&:symbol` BlockArgumentNode) to `param_type` and
1752
+ # returns the resulting type, or `nil` on failure.
1753
+ def apply_hash_block(block_arg, param_type)
1754
+ case block_arg
1755
+ when Prism::BlockNode
1756
+ type_block_body_with_param(block_arg, [param_type])
1757
+ when Prism::BlockArgumentNode
1758
+ expression = block_arg.expression
1759
+ return nil unless expression.is_a?(Prism::SymbolNode)
1760
+
1761
+ MethodDispatcher.dispatch(
1762
+ receiver_type: param_type,
1763
+ method_name: expression.unescaped.to_sym,
1764
+ arg_types: [],
1765
+ block_type: nil,
1766
+ environment: scope.environment,
1767
+ call_node: block_arg,
1768
+ scope: scope
1769
+ )
1770
+ end
1771
+ end
1772
+
1614
1773
  def type_block_body_with_param(block_node, expected_param_types)
1615
1774
  bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
1616
1775
  block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/util"
4
+ require_relative "../../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # Folds `CGI` module-function calls on statically known
10
+ # string constants.
11
+ #
12
+ # `CGI.escapeHTML` / `CGI.unescapeHTML` and related methods
13
+ # are pure, deterministic functions over their string inputs.
14
+ # When the argument is a `Constant[String]`, the analyzer can
15
+ # evaluate the call at inference time and return the concrete
16
+ # `Constant[String]` result.
17
+ #
18
+ # === Supported methods
19
+ #
20
+ # * `escapeHTML(str)` / `escape_html(str)` / `h(str)` —
21
+ # HTML-escape. Returns `Constant[String]`.
22
+ # * `unescapeHTML(str)` / `unescape_html(str)` —
23
+ # HTML-unescape. Returns `Constant[String]`.
24
+ # * `escape(str)` / `unescape(str)` —
25
+ # URL-encode / decode (`application/x-www-form-urlencoded`).
26
+ # Returns `Constant[String]`.
27
+ # * `escapeURIComponent(str)` / `escape_uri_component(str)`,
28
+ # `unescapeURIComponent(str)` / `unescape_uri_component(str)` —
29
+ # URI-component percent-encode / decode. Returns `Constant[String]`.
30
+ # * `escapeElement(str, *elements)` / `escape_element(str, *elements)`,
31
+ # `unescapeElement(str, *elements)` / `unescape_element(str, *elements)` —
32
+ # element-level escape / unescape (first arg is the string,
33
+ # remaining args are element names). Returns `Constant[String]`.
34
+ #
35
+ # === Non-constant / unsupported cases
36
+ #
37
+ # Returns `nil` (deferring to the next dispatcher tier) when:
38
+ # - the receiver is not `Singleton[CGI]`,
39
+ # - the first argument is not a `Constant[String]`,
40
+ # - the method is not in the supported set.
41
+ module CGIFolding
42
+ CGI_HTML_ESCAPE_METHODS = Set[:escapeHTML, :escape_html, :h].freeze
43
+ CGI_HTML_UNESCAPE_METHODS = Set[:unescapeHTML, :unescape_html].freeze
44
+ CGI_URL_ESCAPE_METHODS = Set[:escape, :unescape].freeze
45
+ CGI_URI_ESCAPE_METHODS = Set[
46
+ :escapeURIComponent, :escape_uri_component,
47
+ :unescapeURIComponent, :unescape_uri_component
48
+ ].freeze
49
+ CGI_ELEMENT_ESCAPE_METHODS = Set[
50
+ :escapeElement, :escape_element,
51
+ :unescapeElement, :unescape_element
52
+ ].freeze
53
+ CGI_ALL_ESCAPE_METHODS = (
54
+ CGI_HTML_ESCAPE_METHODS | CGI_HTML_UNESCAPE_METHODS |
55
+ CGI_URL_ESCAPE_METHODS | CGI_URI_ESCAPE_METHODS |
56
+ CGI_ELEMENT_ESCAPE_METHODS
57
+ ).freeze
58
+
59
+ private_constant :CGI_HTML_ESCAPE_METHODS, :CGI_HTML_UNESCAPE_METHODS,
60
+ :CGI_URL_ESCAPE_METHODS, :CGI_URI_ESCAPE_METHODS,
61
+ :CGI_ELEMENT_ESCAPE_METHODS, :CGI_ALL_ESCAPE_METHODS
62
+
63
+ module_function
64
+
65
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
66
+ def try_dispatch(receiver:, method_name:, args:)
67
+ return nil unless dispatch_target?(receiver)
68
+ return nil unless CGI_ALL_ESCAPE_METHODS.include?(method_name)
69
+
70
+ fold_cgi_call(method_name, args)
71
+ end
72
+
73
+ def dispatch_target?(receiver)
74
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "CGI"
75
+ end
76
+
77
+ def fold_cgi_call(method_name, args)
78
+ return nil if args.empty?
79
+ return nil unless args.first.is_a?(Type::Constant) && args.first.value.is_a?(String)
80
+
81
+ str = args.first.value
82
+
83
+ if CGI_ELEMENT_ESCAPE_METHODS.include?(method_name)
84
+ fold_cgi_element(method_name, str, args.drop(1))
85
+ else
86
+ Type::Combinator.constant_of(CGI.public_send(method_name, str))
87
+ end
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # `CGI.escapeElement(str, "elem1", "elem2", ...)` — element-
93
+ # level escape / unescape. The remaining args after the first
94
+ # must be `Constant[String]` element names.
95
+ def fold_cgi_element(method_name, str, element_args)
96
+ elements = element_args.map do |arg|
97
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
98
+
99
+ arg.value
100
+ end
101
+
102
+ Type::Combinator.constant_of(CGI.public_send(method_name, str, *elements))
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -48,11 +48,21 @@ module Rigor
48
48
  module ConstantFolding # rubocop:disable Metrics/ModuleLength
49
49
  module_function
50
50
 
51
- NUMERIC_BINARY = Set[:+, :-, :*, :/, :%, :<, :<=, :>, :>=, :==, :!=, :<=>].freeze
52
- STRING_BINARY = Set[:+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>].freeze
51
+ NUMERIC_BINARY = Set[
52
+ :+, :-, :*, :/, :%, :**, :&, :|, :^, :<<, :>>,
53
+ :<, :<=, :>, :>=, :==, :!=, :<=>,
54
+ :gcd, :lcm, :fdiv
55
+ ].freeze
56
+ STRING_BINARY = Set[
57
+ :+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>,
58
+ :start_with?, :end_with?, :include?,
59
+ :delete_prefix, :delete_suffix,
60
+ :match?, :index, :rindex, :center, :ljust, :rjust
61
+ ].freeze
53
62
  SYMBOL_BINARY = Set[:==, :!=, :<=>, :<, :<=, :>, :>=].freeze
54
- BOOL_BINARY = Set[:&, :|, :^, :==, :!=].freeze
63
+ BOOL_BINARY = Set[:&, :|, :^, :==, :!=, :===].freeze
55
64
  NIL_BINARY = Set[:==, :!=].freeze
65
+ RATIONAL_BINARY = Set[:div, :modulo, :%, :remainder, :fdiv].freeze
56
66
 
57
67
  # v0.0.3 C — pure unary catalogue. Each method must:
58
68
  # - take zero arguments,
@@ -74,20 +84,23 @@ module Rigor
74
84
  :odd?, :even?, :zero?, :positive?, :negative?,
75
85
  :succ, :pred, :next, :abs, :magnitude,
76
86
  :bit_length, :to_s, :to_i, :to_int, :to_f,
87
+ :floor, :ceil, :round, :truncate, :chr,
77
88
  :inspect, :hash, :-@, :+@, :~
78
89
  ].freeze
79
90
  FLOAT_UNARY = Set[
80
91
  :zero?, :positive?, :negative?,
81
92
  :nan?, :finite?, :infinite?,
82
93
  :abs, :magnitude, :floor, :ceil, :round, :truncate,
94
+ :next_float, :prev_float,
83
95
  :to_s, :to_i, :to_int, :to_f,
84
96
  :inspect, :hash, :-@, :+@
85
97
  ].freeze
86
98
  STRING_UNARY = Set[
87
99
  :upcase, :downcase, :capitalize, :swapcase,
88
100
  :reverse, :length, :size, :bytesize,
89
- :empty?, :strip, :lstrip, :rstrip, :chomp,
101
+ :empty?, :strip, :lstrip, :rstrip, :chomp, :chop,
90
102
  :to_s, :to_str, :to_sym, :intern,
103
+ :to_i, :to_f, :ord, :chr, :hex, :oct, :succ, :next,
91
104
  :inspect, :hash
92
105
  ].freeze
93
106
  SYMBOL_UNARY = Set[
@@ -97,6 +110,11 @@ module Rigor
97
110
  ].freeze
98
111
  BOOL_UNARY = Set[:!, :to_s, :inspect, :hash, :&, :|, :^].freeze
99
112
  NIL_UNARY = Set[:nil?, :!, :to_s, :to_a, :to_h, :inspect, :hash].freeze
113
+ RATIONAL_UNARY = Set[
114
+ :zero?, :integer?, :real, :abs2,
115
+ :conj, :conjugate, :nonzero?
116
+ ].freeze
117
+ COMPLEX_UNARY = Set[:zero?, :nonzero?].freeze
100
118
 
101
119
  STRING_FOLD_BYTE_LIMIT = 4096
102
120
 
@@ -283,6 +301,18 @@ module Rigor
283
301
  pathname_lift = try_fold_pathname_unary(receiver_values, method_name)
284
302
  return pathname_lift if pathname_lift
285
303
 
304
+ regexp_lift = try_fold_regexp_array_unary(receiver_values, method_name)
305
+ return regexp_lift if regexp_lift
306
+
307
+ set_lift = try_fold_set_array_unary(receiver_values, method_name)
308
+ return set_lift if set_lift
309
+
310
+ integer_lift = try_fold_integer_array_unary(receiver_values, method_name)
311
+ return integer_lift if integer_lift
312
+
313
+ numeric_lift = try_fold_numeric_array_unary(receiver_values, method_name)
314
+ return numeric_lift if numeric_lift
315
+
286
316
  # Type-level allow check on every receiver. If one member's
287
317
  # type does not have the method in its allow list (e.g.
288
318
  # `Union[String, nil].nil?` — `:nil?` is not in
@@ -309,7 +339,7 @@ module Rigor
309
339
  # Only fires on a single-receiver Range with finite integer
310
340
  # endpoints; mixed unions fall through so the existing
311
341
  # union-of-Constants path keeps the rest of the arms.
312
- RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length].freeze
342
+ RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length, :entries, :minmax].freeze
313
343
  RANGE_TO_A_LIMIT = 16
314
344
  private_constant :RANGE_FOLD_METHODS, :RANGE_TO_A_LIMIT
315
345
 
@@ -326,10 +356,11 @@ module Rigor
326
356
 
327
357
  def range_constant_unary(range, method_name)
328
358
  case method_name
329
- when :to_a then range_to_a_tuple(range)
359
+ when :to_a, :entries then range_to_a_tuple(range)
330
360
  when :first, :min then range_endpoint_constant(range, :first)
331
361
  when :last, :max then range_endpoint_constant(range, :last)
332
362
  when :count, :size, :length then Type::Combinator.constant_of(range.to_a.size)
363
+ when :minmax then range_minmax_tuple(range)
333
364
  end
334
365
  end
335
366
 
@@ -348,6 +379,21 @@ module Rigor
348
379
  Type::Combinator.constant_of(edge == :first ? values.first : values.last)
349
380
  end
350
381
 
382
+ def range_minmax_tuple(range)
383
+ values = range.to_a
384
+ if values.empty?
385
+ return Type::Combinator.tuple_of(
386
+ Type::Combinator.constant_of(nil),
387
+ Type::Combinator.constant_of(nil)
388
+ )
389
+ end
390
+
391
+ Type::Combinator.tuple_of(
392
+ Type::Combinator.constant_of(values.first),
393
+ Type::Combinator.constant_of(values.last)
394
+ )
395
+ end
396
+
351
397
  def try_fold_binary_set(receiver_values, method_name, arg_values)
352
398
  string_lift = try_fold_string_array_binary(receiver_values, method_name, arg_values)
353
399
  return string_lift if string_lift
@@ -377,6 +423,30 @@ module Rigor
377
423
  :STRING_ARRAY_BINARY_METHODS,
378
424
  :STRING_ARRAY_LIFT_LIMIT
379
425
 
426
+ # `Constant<Regexp>#names` returns an Array of capture-group name
427
+ # strings. Lifted to a Tuple so downstream narrowing can project
428
+ # per-element types. The catalog classifies the C body as `:leaf`
429
+ # so it is safe to evaluate at fold time; no `$~` side effect.
430
+ REGEXP_ARRAY_UNARY_METHODS = Set[:names].freeze
431
+ private_constant :REGEXP_ARRAY_UNARY_METHODS
432
+
433
+ # `Constant<Set>#to_a` returns an Array of the set's elements.
434
+ # Ruby 3.2+ Set is C-implemented with a Hash as its backing store,
435
+ # so element ordering is deterministic (insertion order).
436
+ # The catalog marks `to_a` as `:dispatch` (it calls through to the
437
+ # internal hash), so this dedicated handler bypasses the catalog gate.
438
+ SET_ARRAY_UNARY_METHODS = Set[:to_a, :entries].freeze
439
+ private_constant :SET_ARRAY_UNARY_METHODS
440
+
441
+ # `Constant<Integer>#digits` returns the base-10 (or base-n with
442
+ # an argument — only the no-arg form is folded here) place
443
+ # values as a little-endian Array of Integers. Lifted to a
444
+ # Tuple so downstream rules see the precise per-position type.
445
+ # `digits` raises `Math::DomainError` on a negative receiver,
446
+ # so the negative case bails to the RBS tier.
447
+ INTEGER_ARRAY_UNARY_METHODS = Set[:digits].freeze
448
+ private_constant :INTEGER_ARRAY_UNARY_METHODS
449
+
380
450
  # v0.0.7 — `Constant<Pathname>` delegates to a curated set
381
451
  # of pure path-manipulation methods. Pathname is immutable
382
452
  # in Ruby (per its docstring) and the catalog classifies
@@ -446,6 +516,83 @@ module Rigor
446
516
  nil
447
517
  end
448
518
 
519
+ # `Constant<Regexp>#names` — lift the Array[String] of named-capture
520
+ # group names to a Tuple[Constant[String]…]. Safe to evaluate at fold
521
+ # time: the C body reads only the regexp's internal names table,
522
+ # writes no global state, and always returns an Array of frozen Strings.
523
+ def try_fold_regexp_array_unary(receiver_values, method_name)
524
+ return nil unless REGEXP_ARRAY_UNARY_METHODS.include?(method_name)
525
+ return nil unless receiver_values.size == 1
526
+
527
+ receiver = receiver_values.first
528
+ return nil unless receiver.is_a?(Regexp)
529
+
530
+ lift_array_result(receiver.public_send(method_name))
531
+ rescue StandardError
532
+ nil
533
+ end
534
+
535
+ # `Constant<Set>#to_a` / `#entries` — lift the Array of set elements
536
+ # to a Tuple[Constant[…]…] when every element is a foldable scalar.
537
+ # Ruby 3.2+ Set is C-implemented; element order is deterministic
538
+ # (insertion order), so the result is stable across invocations.
539
+ def try_fold_set_array_unary(receiver_values, method_name)
540
+ return nil unless SET_ARRAY_UNARY_METHODS.include?(method_name)
541
+ return nil unless receiver_values.size == 1
542
+
543
+ receiver = receiver_values.first
544
+ return nil unless receiver.is_a?(::Set)
545
+
546
+ lift_array_result(receiver.to_a)
547
+ rescue StandardError
548
+ nil
549
+ end
550
+
551
+ # `Constant<Integer>#digits` — lift the Array of base-10 place
552
+ # values to a Tuple[Constant[Integer]…]. Safe to evaluate at
553
+ # fold time: the C body is pure arithmetic. Negative receivers
554
+ # raise `Math::DomainError`; the fold declines so the RBS tier
555
+ # answers with `Array[Integer]`.
556
+ def try_fold_integer_array_unary(receiver_values, method_name)
557
+ return nil unless INTEGER_ARRAY_UNARY_METHODS.include?(method_name)
558
+ return nil unless receiver_values.size == 1
559
+
560
+ receiver = receiver_values.first
561
+ return nil unless receiver.is_a?(Integer)
562
+ return nil if receiver.negative?
563
+
564
+ lift_array_result(receiver.digits)
565
+ rescue StandardError
566
+ nil
567
+ end
568
+
569
+ # `Constant<Complex>#rect` / `#rectangular` — lifts `[real, imaginary]`
570
+ # to `Tuple[Constant[re], Constant[im]]`. Both components are always
571
+ # numeric (Integer or Float for literal complexes), so they satisfy
572
+ # `foldable_constant_value?`.
573
+ #
574
+ # `Constant<Complex>#polar` — lifts `[abs, arg]` to
575
+ # `Tuple[Constant[Float], Constant[Float]]`. Evaluated at fold time
576
+ # via `Complex#polar` (which calls `Math.hypot` and `Math.atan2`).
577
+ # Deterministic: reads only the receiver's real and imaginary parts.
578
+ #
579
+ # Rational receivers also support `rect` / `rectangular` / `polar`:
580
+ # `Rational(r,1).rect` → `[r, 0]`, `Rational(r,1).polar` → `[abs, arg]`.
581
+ NUMERIC_ARRAY_UNARY_METHODS = Set[:rect, :rectangular, :polar].freeze
582
+ private_constant :NUMERIC_ARRAY_UNARY_METHODS
583
+
584
+ def try_fold_numeric_array_unary(receiver_values, method_name)
585
+ return nil unless NUMERIC_ARRAY_UNARY_METHODS.include?(method_name)
586
+ return nil unless receiver_values.size == 1
587
+
588
+ receiver = receiver_values.first
589
+ return nil unless receiver.is_a?(Complex) || receiver.is_a?(Rational)
590
+
591
+ lift_array_result(receiver.public_send(method_name))
592
+ rescue StandardError
593
+ nil
594
+ end
595
+
449
596
  # `Constant<String>#split(arg)` / `#scan(arg)` — lift the
450
597
  # Array result to a Tuple when both sides are statically
451
598
  # known and the cardinality fits.
@@ -1101,6 +1248,8 @@ module Rigor
1101
1248
  when Symbol then SYMBOL_UNARY
1102
1249
  when true, false then BOOL_UNARY
1103
1250
  when nil then NIL_UNARY
1251
+ when Rational then RATIONAL_UNARY
1252
+ when Complex then COMPLEX_UNARY
1104
1253
  else Set.new
1105
1254
  end
1106
1255
  end
@@ -1126,11 +1275,15 @@ module Rigor
1126
1275
  # have their own shape carriers; this method picks
1127
1276
  # the conservative envelope of "values that already
1128
1277
  # round-trip through `Type::Combinator.constant_of`".
1278
+ FOLDABLE_CONSTANT_CLASSES = [
1279
+ Integer, Float, Rational, Complex, String, Symbol,
1280
+ Regexp, Pathname, ::Set, Date, Time,
1281
+ TrueClass, FalseClass, NilClass
1282
+ ].freeze
1283
+ private_constant :FOLDABLE_CONSTANT_CLASSES
1284
+
1129
1285
  def foldable_constant_value?(value)
1130
- case value
1131
- when Integer, Float, Rational, Complex, String, Symbol, Regexp, Pathname, true, false, nil then true
1132
- else false
1133
- end
1286
+ FOLDABLE_CONSTANT_CLASSES.any? { |klass| value.is_a?(klass) }
1134
1287
  end
1135
1288
 
1136
1289
  def safe?(receiver_value, method_name, arg_value)
@@ -1150,6 +1303,7 @@ module Rigor
1150
1303
  when Symbol then SYMBOL_BINARY
1151
1304
  when true, false then BOOL_BINARY
1152
1305
  when nil then NIL_BINARY
1306
+ when Rational then RATIONAL_BINARY
1153
1307
  else Set.new
1154
1308
  end
1155
1309
  end
@@ -1169,10 +1323,19 @@ module Rigor
1169
1323
  case method_name
1170
1324
  when :+ then string_concat_blow_up?(receiver_value, arg_value)
1171
1325
  when :* then string_repeat_blow_up?(receiver_value, arg_value)
1326
+ when :center, :ljust, :rjust then string_pad_blow_up?(arg_value)
1172
1327
  else false
1173
1328
  end
1174
1329
  end
1175
1330
 
1331
+ # `"x".center(width)` / `#ljust` / `#rjust` produce a string
1332
+ # of `max(width, len)` characters. A literal `width` far
1333
+ # larger than the receiver would materialise a huge Constant;
1334
+ # cap it at the same byte limit the concat / repeat paths use.
1335
+ def string_pad_blow_up?(arg_value)
1336
+ arg_value.is_a?(Integer) && arg_value > STRING_FOLD_BYTE_LIMIT
1337
+ end
1338
+
1176
1339
  def string_concat_blow_up?(receiver_value, arg_value)
1177
1340
  arg_value.is_a?(String) &&
1178
1341
  receiver_value.bytesize + arg_value.bytesize > STRING_FOLD_BYTE_LIMIT
@@ -57,11 +57,63 @@ module Rigor
57
57
  return nil if receiver.nil?
58
58
  return try_array(args) if method_name == :Array
59
59
  return try_numeric_constructor(method_name, args) if NUMERIC_CONSTRUCTORS.key?(method_name)
60
- return try_integer_from_refinement(args) if method_name == :Integer
60
+ return try_integer(args) if method_name == :Integer
61
+ return try_float(args) if method_name == :Float
61
62
 
62
63
  nil
63
64
  end
64
65
 
66
+ # `Kernel#Integer(arg)` / `Integer(arg, base)`. Two folding
67
+ # paths, tried in order:
68
+ #
69
+ # 1. A `Refined[String, predicate]` argument whose predicate
70
+ # is a digit-only carrier narrows to `non-negative-int`
71
+ # (see {try_integer_from_refinement}).
72
+ # 2. A `Constant` String or Numeric argument — optionally
73
+ # with a `Constant[Integer]` base — runs the actual
74
+ # `Integer()` conversion and lifts the result to
75
+ # `Constant[Integer]`.
76
+ def try_integer(args)
77
+ refined = try_integer_from_refinement(args)
78
+ return refined if refined
79
+
80
+ try_integer_constant(args)
81
+ end
82
+
83
+ # Constant-folding path for `Integer()`. A non-parseable
84
+ # string raises `ArgumentError` (or `TypeError` for a base
85
+ # against a non-string) at fold time; the handler declines
86
+ # so the RBS tier answers with the widened `Integer`.
87
+ def try_integer_constant(args)
88
+ return nil unless [1, 2].include?(args.size)
89
+ return nil unless args.all?(Type::Constant)
90
+
91
+ values = args.map(&:value)
92
+ return nil unless values[0].is_a?(String) || values[0].is_a?(Numeric)
93
+ return nil if values.size == 2 && !values[1].is_a?(Integer)
94
+
95
+ Type::Combinator.constant_of(Integer(*values))
96
+ rescue ArgumentError, TypeError
97
+ nil
98
+ end
99
+
100
+ # `Kernel#Float(arg)` — folds a `Constant` String or Numeric
101
+ # argument to `Constant[Float]`. A non-parseable string
102
+ # raises `ArgumentError` at fold time; the handler declines.
103
+ def try_float(args)
104
+ return nil unless args.size == 1
105
+
106
+ arg = args.first
107
+ return nil unless arg.is_a?(Type::Constant)
108
+
109
+ value = arg.value
110
+ return nil unless value.is_a?(String) || value.is_a?(Numeric)
111
+
112
+ Type::Combinator.constant_of(Float(value))
113
+ rescue ArgumentError, TypeError
114
+ nil
115
+ end
116
+
65
117
  # `Kernel#Integer(s)` over a `Refined[String, predicate]`
66
118
  # whose predicate is in {INTEGER_REFINEMENT_PREDICATES}.
67
119
  # Mirrors the `String#to_i` projection in `ShapeDispatch`