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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `Date` and `DateTime` live in CRuby's bundled `date` gem, which
4
+ # is stdlib rather than core — so the constants are not visible
5
+ # until `date` is required. The dispatcher's `CATALOG_BY_CLASS`
6
+ # table references `Date` and `DateTime` at load time, so requiring
7
+ # the gem here (alongside the loader file that exports the catalog)
8
+ # keeps the wiring self-contained: a consumer that pulls in the
9
+ # constant-folding rule book gets the Date constants for free.
10
+ require "date"
11
+
12
+ require_relative "method_catalog"
13
+
14
+ module Rigor
15
+ module Inference
16
+ module Builtins
17
+ # `Date` / `DateTime` catalog. Singleton — load once, consult
18
+ # during dispatch.
19
+ #
20
+ # `Date` and `DateTime` both come from CRuby's bundled `date`
21
+ # gem (`references/ruby/ext/date/date_core.c`). A single
22
+ # `Init_date_core` function registers them, so the catalog
23
+ # carries both classes — `Date` plus the `DateTime` subclass
24
+ # whose own Init block extends with `hour` / `min` /
25
+ # `strftime` / `iso8601` etc. The Ruby-side prelude
26
+ # (`lib/date.rb`) only contributes `Date#infinite?` and the
27
+ # nested `Date::Infinity` class; the bulk of the surface is
28
+ # in C.
29
+ #
30
+ # Date / DateTime receivers are not lifted to a `Constant`
31
+ # carrier today (there is no Date literal node — the closest
32
+ # is `Date.today` / `Date.parse(...)`, which produce
33
+ # `Nominal[Date]`). The catalog wiring therefore mostly
34
+ # governs:
35
+ #
36
+ # 1. The Integer-typed reader surface (`#year`, `#month`,
37
+ # `#day`, `#wday`, `#hour`, `#min`, `#sec`) — RBS-declared
38
+ # `Integer` is preserved through dispatch.
39
+ # 2. The blocklist below, which keeps mutator-style methods
40
+ # that the C-body classifier already flagged
41
+ # (`mutates_self`) from being missed by a future
42
+ # `Constant<Date>` carrier, plus a defensive
43
+ # `:initialize_copy` entry for symmetry with the other
44
+ # catalogs.
45
+ #
46
+ # The non-bang `#next_day` / `#prev_day` / `#next_month` /
47
+ # `#prev_month` / `#next_year` / `#prev_year` / `#>>` / `#<<`
48
+ # selectors all RETURN brand-new `Date` objects rather than
49
+ # mutating the receiver — they intentionally stay
50
+ # catalog-eligible. The two real mutators
51
+ # (`#initialize_copy`, `#marshal_load`) are already classified
52
+ # `:mutates_self` by the C-body regex, so they fall out of
53
+ # `MethodCatalog#safe_for_folding?` without an explicit
54
+ # blocklist entry; the entries below are defense-in-depth
55
+ # against indirect mutators the regex might miss in a future
56
+ # CRuby bump.
57
+ DATE_CATALOG = MethodCatalog.new(
58
+ path: File.expand_path(
59
+ "../../../../data/builtins/ruby_core/date.yml",
60
+ __dir__
61
+ ),
62
+ mutating_selectors: {
63
+ "Date" => Set[
64
+ # `d_lite_initialize_copy` is already classed
65
+ # `:mutates_self` by the regex (it calls
66
+ # `rb_check_frozen` and rewrites the receiver's
67
+ # internal `dat` slots). Listed here for symmetry with
68
+ # String / Array / Range / Set / Time and to keep the
69
+ # blocklist self-documenting.
70
+ :initialize_copy,
71
+ # `d_lite_fill` is a `#ifndef NDEBUG` debug method that
72
+ # warms the receiver's cached `simple` / `complex`
73
+ # fields via the `get_s_*` / `get_c_*` macros. The
74
+ # macros perform in-place writes on the receiver's
75
+ # internal `dat` struct but use no helper the C-body
76
+ # regex recognises, so the classifier mis-flags it
77
+ # `:leaf`. Blocked so a future `Constant<Date>` carrier
78
+ # never folds it.
79
+ :fill
80
+ ],
81
+ "DateTime" => Set[
82
+ # `DateTime` inherits the bulk of its surface from
83
+ # `Date`. The dedicated DateTime-side methods are all
84
+ # readers (`hour`, `min`, …) plus formatting
85
+ # converters (`strftime`, `iso8601`, …); none mutate
86
+ # the receiver. The single defensive entry mirrors the
87
+ # Date side so that the inherited
88
+ # `Date#initialize_copy` (registered against
89
+ # `cDateTime` through subclassing) cannot fold through
90
+ # the catalog if a future `Constant<DateTime>` carrier
91
+ # ever lands.
92
+ :initialize_copy
93
+ ]
94
+ }
95
+ )
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Enumerable` module catalog. Singleton — load once.
9
+ #
10
+ # `Enumerable` is a Ruby module, not a class, so the
11
+ # catalog is NOT routed through
12
+ # `MethodDispatcher::ConstantFolding::CATALOG_BY_CLASS`
13
+ # (which dispatches on the receiver's concrete class).
14
+ # The data is consumed by future include-aware lookup —
15
+ # see `docs/CURRENT_WORK.md` for the planned slice.
16
+ ENUMERABLE_CATALOG = MethodCatalog.new(
17
+ path: File.expand_path(
18
+ "../../../../data/builtins/ruby_core/enumerable.yml",
19
+ __dir__
20
+ ),
21
+ mutating_selectors: {
22
+ "Enumerable" => Set[]
23
+ }
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Hash` catalog. Singleton — load once, consult during dispatch.
9
+ #
10
+ # Hash mirrors Array's mutation pattern: nearly every iteration
11
+ # method yields through `rb_hash_foreach` plus a per-pair static
12
+ # callback (`each_value_i`, `keep_if_i`, …), and the C-body
13
+ # classifier does not follow into the callback so it lands as
14
+ # `:leaf` despite being block-dependent. The blocklist below
15
+ # captures every false-positive `:leaf` we have spotted in the
16
+ # generated YAML — bias toward conservatism so a missed fold is
17
+ # acceptable but a folded mutator/yielder is not.
18
+ HASH_CATALOG = MethodCatalog.new(
19
+ path: File.expand_path(
20
+ "../../../../data/builtins/ruby_core/hash.yml",
21
+ __dir__
22
+ ),
23
+ mutating_selectors: {
24
+ "Hash" => Set[
25
+ # Block-dependent iteration — yields via `rb_hash_foreach`
26
+ # plus a per-pair callback that the regex classifier does
27
+ # not follow:
28
+ :each, :each_pair, :each_key, :each_value,
29
+ :select, :filter, :reject,
30
+ :transform_values,
31
+ # Block-dependent merge — `rb_hash_merge` delegates into
32
+ # `rb_hash_update`, which yields per conflict when a block
33
+ # is given:
34
+ :merge
35
+ ]
36
+ }
37
+ )
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Range` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # Range is largely immutable: `begin`, `end`, and `excl` are
12
+ # set at construction by `range_initialize` and never mutated
13
+ # afterwards. The blocklist below therefore stays small. The
14
+ # entries we DO need are the iteration methods whose C body
15
+ # routes through a helper the block/yield regex does not
16
+ # recognise, so the classifier mis-flags them as `:leaf`
17
+ # despite yielding to a block.
18
+ RANGE_CATALOG = MethodCatalog.new(
19
+ path: File.expand_path(
20
+ "../../../../data/builtins/ruby_core/range.yml",
21
+ __dir__
22
+ ),
23
+ mutating_selectors: {
24
+ "Range" => Set[
25
+ # `range_initialize` / `range_initialize_copy` write
26
+ # `begin`/`end`/`excl` slots on the receiver; classed
27
+ # `:leaf` because the writes go through the struct
28
+ # accessor not `rb_check_frozen`. Blocked for symmetry
29
+ # with String / Array.
30
+ :initialize, :initialize_copy,
31
+ # `range_reverse_each` yields to its block via
32
+ # `range_each_func` -> caller's block; the regex
33
+ # classifier follows direct `rb_yield*` calls only.
34
+ :reverse_each,
35
+ # `range_percent_step` returns an Enumerator unless a
36
+ # block is supplied, in which case it yields. Treated
37
+ # as block-dependent so the fold tier never invokes it
38
+ # against a literal Range and tries to materialise an
39
+ # Enumerator into a Constant.
40
+ :%
41
+ ]
42
+ }
43
+ )
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Rational` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # Rational is fully immutable: numerator / denominator slots
12
+ # are written once during `nurat_s_new_internal` and the C
13
+ # body never reaches for `rb_check_frozen`. Every catalog
14
+ # entry classifies cleanly (`:leaf`, `:leaf_when_numeric`,
15
+ # or `:dispatch` for the two methods that delegate into
16
+ # user-redefinable `==` / `Float()` — `nurat_eqeq_p` and
17
+ # `nurat_fdiv`). Bang-suffixed mutators do not exist on
18
+ # Rational.
19
+ #
20
+ # The blocklist therefore stays minimal. `initialize_copy`
21
+ # is added defensively (mirrors Range / Set) so a
22
+ # hypothetical future `Constant<Rational>` carrier cannot
23
+ # fold an aliasing copy through the catalog and surface a
24
+ # shared mutable handle.
25
+ RATIONAL_CATALOG = MethodCatalog.new(
26
+ path: File.expand_path(
27
+ "../../../../data/builtins/ruby_core/rational.yml",
28
+ __dir__
29
+ ),
30
+ mutating_selectors: {
31
+ "Rational" => Set[
32
+ :initialize_copy
33
+ ]
34
+ }
35
+ )
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Set` catalog. Singleton — load once, consult during dispatch.
9
+ #
10
+ # Set was rewritten in C and folded into CRuby for Ruby 3.2+;
11
+ # the reference branch (`ruby_4_0`) ships the implementation in
12
+ # `references/ruby/set.c` with `Init_Set` registering every
13
+ # method directly. There is no `set.rb` prelude — the trailing
14
+ # `rb_provide("set.rb")` makes `require "set"` a no-op against
15
+ # the built-in.
16
+ #
17
+ # The blocklist below catches the catalog `:leaf` entries the
18
+ # C-body classifier mis-attributes. Set's iteration helpers
19
+ # (`set_iter`, `RETURN_SIZED_ENUMERATOR`) and its identity-
20
+ # mode and reset paths drive into helpers the regex classifier
21
+ # does not yet recognise as block-yielding or mutating.
22
+ SET_CATALOG = MethodCatalog.new(
23
+ path: File.expand_path(
24
+ "../../../../data/builtins/ruby_core/set.yml",
25
+ __dir__
26
+ ),
27
+ mutating_selectors: {
28
+ "Set" => Set[
29
+ # Indirect mutators classified `:leaf` because the C
30
+ # classifier did not follow the helper functions:
31
+ #
32
+ # - `initialize_copy` calls `set_copy` to overwrite the
33
+ # receiver's table.
34
+ # - `compare_by_identity` swaps the internal hash type
35
+ # via `set_reset_table_with_type`.
36
+ # - `reset` rebuilds the internal table to dedup after
37
+ # element mutation.
38
+ :initialize_copy, :compare_by_identity, :reset,
39
+ # Block-dependent methods classified `:leaf` because the
40
+ # C body uses `set_iter` / `RETURN_SIZED_ENUMERATOR`
41
+ # rather than calling `rb_yield` directly:
42
+ :each, :classify, :divide,
43
+ # `disjoint?` delegates into `set_i_intersect`, which
44
+ # for non-Set enumerables uses `rb_funcall(other,
45
+ # :any?, ...)` — that is user-redefinable dispatch the
46
+ # classifier missed because the call site is in a
47
+ # sibling function.
48
+ :disjoint?
49
+ ]
50
+ }
51
+ )
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Time` catalog. Singleton — load once, consult during dispatch.
9
+ #
10
+ # Time is a pure-C built-in: the Init block in
11
+ # `references/ruby/time.c` registers the bulk of the surface,
12
+ # and the Ruby-side prelude `references/ruby/timev.rb`
13
+ # contributes the class-side constructors (`Time.now`,
14
+ # `Time.at`, `Time.new`) through Primitive cexpr stubs.
15
+ #
16
+ # Time receivers are not lifted to a `Constant` carrier today
17
+ # (there is no `Time` literal node — the closest is
18
+ # `Time.now` / `Time.new(...)`, which produce `Nominal[Time]`).
19
+ # The catalog wiring therefore mostly governs:
20
+ #
21
+ # 1. The size-projection-equivalent reader surface (`#year`,
22
+ # `#month`, `#hour`, `#sec`, `#wday`, …) — RBS-declared
23
+ # `Integer` is preserved through dispatch.
24
+ # 2. The blocklist below, which keeps the indirect-mutator
25
+ # methods that the C-body classifier mis-flagged as
26
+ # `:leaf` from ever folding through a hypothetical future
27
+ # `Constant<Time>` carrier.
28
+ #
29
+ # The blocklist captures the false-positive `:leaf` entries
30
+ # whose helper functions the regex classifier did not
31
+ # recognise as mutators.
32
+ TIME_CATALOG = MethodCatalog.new(
33
+ path: File.expand_path(
34
+ "../../../../data/builtins/ruby_core/time.yml",
35
+ __dir__
36
+ ),
37
+ mutating_selectors: {
38
+ "Time" => Set[
39
+ # `time_init_copy` writes the `timew` and `vtm` slots on
40
+ # the receiver via `time_set_timew` / `time_set_vtm`.
41
+ # Classed `:leaf` because those setters are not in the
42
+ # mutator regex's helper list. Blocked for symmetry with
43
+ # String / Array / Range / Set initialize_copy entries.
44
+ :initialize_copy,
45
+ # `time_localtime_m` -> `time_localtime` calls
46
+ # `time_modify(time)` to mark the receiver mutable
47
+ # before rewriting its `vtm` cache and `tzmode`. The
48
+ # docstring is explicit ("converts time to local time
49
+ # in place"). The C-body classifier mis-flagged it as
50
+ # `:leaf` because `time_modify` is not in its mutator
51
+ # regex.
52
+ :localtime,
53
+ # `time_gmtime` (registered as both `gmtime` and `utc`
54
+ # against `rb_cTime`) follows the same in-place pattern
55
+ # as `time_localtime`: `time_modify(time)` then a
56
+ # `time_set_vtm` write and `TZMODE_SET_UTC`. Both
57
+ # selectors share the cfunc, so both must be blocked.
58
+ :gmtime, :utc
59
+ ]
60
+ }
61
+ )
62
+ end
63
+ end
64
+ end
@@ -4,6 +4,15 @@ require_relative "../../type"
4
4
  require_relative "../builtins/numeric_catalog"
5
5
  require_relative "../builtins/string_catalog"
6
6
  require_relative "../builtins/array_catalog"
7
+ require_relative "../builtins/hash_catalog"
8
+ require_relative "../builtins/range_catalog"
9
+ require_relative "../builtins/set_catalog"
10
+ require_relative "../builtins/time_catalog"
11
+ require_relative "../builtins/date_catalog"
12
+ require_relative "../builtins/comparable_catalog"
13
+ require_relative "../builtins/enumerable_catalog"
14
+ require_relative "../builtins/rational_catalog"
15
+ require_relative "../builtins/complex_catalog"
7
16
 
8
17
  module Rigor
9
18
  module Inference
@@ -105,9 +114,14 @@ module Rigor
105
114
  arg_sets = args.map { |a| numeric_set_of(a) }
106
115
  return nil if arg_sets.any?(&:nil?)
107
116
 
108
- case args.size
117
+ dispatch_by_arity(receiver_set, method_name, arg_sets)
118
+ end
119
+
120
+ def dispatch_by_arity(receiver_set, method_name, arg_sets)
121
+ case arg_sets.size
109
122
  when 0 then try_fold_unary(receiver_set, method_name)
110
123
  when 1 then try_fold_binary(receiver_set, method_name, arg_sets.first)
124
+ when 2 then try_fold_ternary(receiver_set, method_name, arg_sets)
111
125
  end
112
126
  end
113
127
 
@@ -216,6 +230,38 @@ module Rigor
216
230
  build_constant_type(results, source: receiver_values + arg_values)
217
231
  end
218
232
 
233
+ # 2-arg fold dispatch. Used by `Comparable#between?(min, max)`,
234
+ # `Comparable#clamp(min, max)`, and `Integer#pow(exp, mod)` —
235
+ # methods the catalog classifies `:leaf` but that the prior
236
+ # 0/1-arg switch could not reach. Range receivers/args are
237
+ # held back: a precise 2-arg range fold (e.g.
238
+ # `int<0,10>.between?(0, 10)` → `Constant[true]`) is a
239
+ # follow-up; for now any IntegerRange operand bails to the
240
+ # RBS tier.
241
+ def try_fold_ternary(receiver_set, method_name, arg_sets)
242
+ return nil if receiver_set.is_a?(Type::IntegerRange)
243
+ return nil if arg_sets.any?(Type::IntegerRange)
244
+
245
+ try_fold_ternary_set(receiver_set, method_name, arg_sets)
246
+ end
247
+
248
+ def try_fold_ternary_set(receiver_values, method_name, arg_sets)
249
+ total = receiver_values.size * arg_sets[0].size * arg_sets[1].size
250
+ return nil if total > UNION_FOLD_INPUT_LIMIT
251
+ return nil unless receiver_values.all? { |rv| ternary_method_allowed?(rv, method_name) }
252
+
253
+ results = ternary_cartesian(receiver_values, method_name, arg_sets)
254
+ build_constant_type(results, source: receiver_values + arg_sets.flatten)
255
+ end
256
+
257
+ def ternary_cartesian(receiver_values, method_name, arg_sets)
258
+ receiver_values.flat_map do |rv|
259
+ arg_sets[0].flat_map do |av0|
260
+ arg_sets[1].flat_map { |av1| invoke_ternary(rv, method_name, av0, av1) || [] }
261
+ end
262
+ end
263
+ end
264
+
219
265
  def unary_method_allowed?(receiver_value, method_name)
220
266
  unary_ops_for(receiver_value).include?(method_name) ||
221
267
  catalog_allows?(receiver_value, method_name)
@@ -226,6 +272,13 @@ module Rigor
226
272
  catalog_allows?(receiver_value, method_name)
227
273
  end
228
274
 
275
+ # 2-arg methods have no hand-rolled allow list; the catalog
276
+ # is the sole gate. Adding a per-class arity-2 set is reserved
277
+ # for future cases that need it.
278
+ def ternary_method_allowed?(receiver_value, method_name)
279
+ catalog_allows?(receiver_value, method_name)
280
+ end
281
+
229
282
  # Builds a Constant or Union[Constant…] from a flat list of
230
283
  # Ruby values. When the deduped set exceeds
231
284
  # `UNION_FOLD_OUTPUT_LIMIT` and every result is an Integer,
@@ -594,6 +647,19 @@ module Rigor
594
647
  nil
595
648
  end
596
649
 
650
+ # Returns `[value]` on success, `nil` to signal "skip this triple".
651
+ # Mirrors `invoke_binary` but for the 2-argument shape; the wrap
652
+ # convention lets callers `flat_map` without losing
653
+ # legitimate `false`/`nil` folds.
654
+ def invoke_ternary(receiver_value, method_name, av0, av1)
655
+ return nil unless ternary_method_allowed?(receiver_value, method_name)
656
+
657
+ result = receiver_value.public_send(method_name, av0, av1)
658
+ foldable_constant_value?(result) ? [result] : nil
659
+ rescue StandardError
660
+ nil
661
+ end
662
+
597
663
  # Returns `[value]` on success, `nil` to signal "skip". See
598
664
  # `invoke_binary` for why we wrap.
599
665
  def invoke_unary(receiver_value, method_name)
@@ -619,24 +685,92 @@ module Rigor
619
685
  # implementation does not call back into user-redefinable
620
686
  # Ruby methods, so executing them on a literal Integer/Float
621
687
  # is safe regardless of monkey-patching.
688
+ #
689
+ # Resolution order:
690
+ #
691
+ # 1. Primary class catalog (e.g. NumericCatalog for an
692
+ # Integer receiver). When the catalog has an entry —
693
+ # even one classified `:dispatch` — that answer wins.
694
+ # The class's direct `rb_define_method` registration is
695
+ # authoritative; we MUST NOT fall through to a module
696
+ # catalog and risk over-folding.
697
+ # 2. Module catalogs (Comparable, Enumerable, …) that the
698
+ # receiver's class includes by ancestry. Reached only
699
+ # when the primary catalog has NO entry for the method
700
+ # — typically because the method is inherited purely
701
+ # through `include Comparable` / `include Enumerable`
702
+ # (e.g. `Integer#between?` / `Integer#clamp` are not in
703
+ # numeric.yml because the Init block does not
704
+ # `rb_define_method` them on Integer).
622
705
  def catalog_allows?(receiver_value, method_name)
623
706
  catalog, class_name = catalog_for(receiver_value)
624
- return false unless catalog
707
+ return catalog.safe_for_folding?(class_name, method_name) if catalog&.method_entry(class_name, method_name)
708
+
709
+ module_catalogs_for(receiver_value).any? do |mod_catalog, mod_name|
710
+ mod_catalog.method_entry(mod_name, method_name) &&
711
+ mod_catalog.safe_for_folding?(mod_name, method_name)
712
+ end
713
+ end
625
714
 
626
- catalog.safe_for_folding?(class_name, method_name)
715
+ # `(Module, catalog, class_name)` triples consulted as a
716
+ # fallthrough when the primary class catalog has no entry.
717
+ # Each triple's Module is matched against the receiver
718
+ # class's ancestor chain at lookup time; the catalog
719
+ # corresponds to the module-mode YAML at
720
+ # `data/builtins/ruby_core/<topic>.yml`.
721
+ MODULE_CATALOGS = [
722
+ [Comparable, Builtins::COMPARABLE_CATALOG, "Comparable"],
723
+ [Enumerable, Builtins::ENUMERABLE_CATALOG, "Enumerable"]
724
+ ].freeze
725
+ private_constant :MODULE_CATALOGS
726
+
727
+ # Returns the `(catalog, class_name)` pairs for every
728
+ # registered module that is in the receiver's ancestor
729
+ # chain. The receiver's class's `Module#ancestors` is
730
+ # cached by Ruby; the `Set` membership check is cheap.
731
+ def module_catalogs_for(receiver_value)
732
+ ancestors = Set.new(receiver_value.class.ancestors)
733
+ MODULE_CATALOGS.filter_map do |mod, catalog, class_name|
734
+ [catalog, class_name] if ancestors.include?(mod)
735
+ end
627
736
  end
628
737
 
738
+ # `(catalog, class_name)` per receiver class. The class_name
739
+ # is what each catalog's RBS-rooted entries are keyed by.
740
+ # `catalog_for` walks this table in declaration order so
741
+ # subclasses (Symbol < String) hit their dedicated entry
742
+ # before any base-class fallback would, and adding a new
743
+ # class is a one-line addition rather than another `when`
744
+ # arm on a growing case statement.
745
+ # Subclass-before-superclass ordering: `DateTime < Date`,
746
+ # so the `DateTime` row MUST come before the `Date` row.
747
+ # Otherwise a `DateTime` receiver would match the `Date`
748
+ # arm first and the catalog would consult the Date entry
749
+ # in `DATE_CATALOG` for the wrong class.
750
+ CATALOG_BY_CLASS = [
751
+ [Integer, [Builtins::NumericCatalog, "Integer"]],
752
+ [Float, [Builtins::NumericCatalog, "Float"]],
753
+ [String, [Builtins::STRING_CATALOG, "String"]],
754
+ [Symbol, [Builtins::STRING_CATALOG, "Symbol"]],
755
+ [Array, [Builtins::ARRAY_CATALOG, "Array"]],
756
+ [Hash, [Builtins::HASH_CATALOG, "Hash"]],
757
+ [Range, [Builtins::RANGE_CATALOG, "Range"]],
758
+ [::Set, [Builtins::SET_CATALOG, "Set"]],
759
+ [Time, [Builtins::TIME_CATALOG, "Time"]],
760
+ [DateTime, [Builtins::DATE_CATALOG, "DateTime"]],
761
+ [Date, [Builtins::DATE_CATALOG, "Date"]],
762
+ [Rational, [Builtins::RATIONAL_CATALOG, "Rational"]],
763
+ [Complex, [Builtins::COMPLEX_CATALOG, "Complex"]]
764
+ ].freeze
765
+ private_constant :CATALOG_BY_CLASS
766
+
629
767
  # 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.
768
+ # catalog for; nil otherwise.
632
769
  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"]
770
+ CATALOG_BY_CLASS.each do |klass, entry|
771
+ return entry if receiver_value.is_a?(klass)
639
772
  end
773
+ nil
640
774
  end
641
775
 
642
776
  def unary_ops_for(receiver_value)