rigortype 0.0.4 → 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.
@@ -8,6 +8,11 @@ require_relative "../builtins/hash_catalog"
8
8
  require_relative "../builtins/range_catalog"
9
9
  require_relative "../builtins/set_catalog"
10
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"
11
16
 
12
17
  module Rigor
13
18
  module Inference
@@ -109,9 +114,14 @@ module Rigor
109
114
  arg_sets = args.map { |a| numeric_set_of(a) }
110
115
  return nil if arg_sets.any?(&:nil?)
111
116
 
112
- 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
113
122
  when 0 then try_fold_unary(receiver_set, method_name)
114
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)
115
125
  end
116
126
  end
117
127
 
@@ -220,6 +230,38 @@ module Rigor
220
230
  build_constant_type(results, source: receiver_values + arg_values)
221
231
  end
222
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
+
223
265
  def unary_method_allowed?(receiver_value, method_name)
224
266
  unary_ops_for(receiver_value).include?(method_name) ||
225
267
  catalog_allows?(receiver_value, method_name)
@@ -230,6 +272,13 @@ module Rigor
230
272
  catalog_allows?(receiver_value, method_name)
231
273
  end
232
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
+
233
282
  # Builds a Constant or Union[Constant…] from a flat list of
234
283
  # Ruby values. When the deduped set exceeds
235
284
  # `UNION_FOLD_OUTPUT_LIMIT` and every result is an Integer,
@@ -598,6 +647,19 @@ module Rigor
598
647
  nil
599
648
  end
600
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
+
601
663
  # Returns `[value]` on success, `nil` to signal "skip". See
602
664
  # `invoke_binary` for why we wrap.
603
665
  def invoke_unary(receiver_value, method_name)
@@ -623,11 +685,54 @@ module Rigor
623
685
  # implementation does not call back into user-redefinable
624
686
  # Ruby methods, so executing them on a literal Integer/Float
625
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).
626
705
  def catalog_allows?(receiver_value, method_name)
627
706
  catalog, class_name = catalog_for(receiver_value)
628
- return false unless catalog
707
+ return catalog.safe_for_folding?(class_name, method_name) if catalog&.method_entry(class_name, method_name)
629
708
 
630
- catalog.safe_for_folding?(class_name, method_name)
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
714
+
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
631
736
  end
632
737
 
633
738
  # `(catalog, class_name)` per receiver class. The class_name
@@ -637,16 +742,25 @@ module Rigor
637
742
  # before any base-class fallback would, and adding a new
638
743
  # class is a one-line addition rather than another `when`
639
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.
640
750
  CATALOG_BY_CLASS = [
641
- [Integer, [Builtins::NumericCatalog, "Integer"]],
642
- [Float, [Builtins::NumericCatalog, "Float"]],
643
- [String, [Builtins::STRING_CATALOG, "String"]],
644
- [Symbol, [Builtins::STRING_CATALOG, "Symbol"]],
645
- [Array, [Builtins::ARRAY_CATALOG, "Array"]],
646
- [Hash, [Builtins::HASH_CATALOG, "Hash"]],
647
- [Range, [Builtins::RANGE_CATALOG, "Range"]],
648
- [::Set, [Builtins::SET_CATALOG, "Set"]],
649
- [Time, [Builtins::TIME_CATALOG, "Time"]]
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"]]
650
764
  ].freeze
651
765
  private_constant :CATALOG_BY_CLASS
652
766
 
@@ -32,14 +32,20 @@ module Rigor
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)
40
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)
41
46
  end
42
47
  end
48
+ # rubocop:enable Metrics/CyclomaticComplexity
43
49
 
44
50
  def times_block_params(receiver)
45
51
  return nil unless integer_rooted?(receiver)
@@ -88,6 +94,99 @@ module Rigor
88
94
  [element, Type::Combinator.non_negative_int]
89
95
  end
90
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
+
91
190
  ELEMENT_BY_NOMINAL = {
92
191
  "Array" => :nominal_unary_element,
93
192
  "Set" => :nominal_unary_element,
@@ -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
@@ -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)
@@ -241,6 +241,55 @@ module Rigor
241
241
  narrow_class_dispatch(type, class_name, context)
242
242
  end
243
243
 
244
+ # Negation pair for `assert_value is ~refinement` /
245
+ # `predicate-if-* … is ~refinement` directives. Computes
246
+ # the complement of `refinement` within the current
247
+ # local's domain `current_type`.
248
+ #
249
+ # Carrier-by-carrier rules:
250
+ #
251
+ # - `Difference[base, Constant[v]]`. Complement of
252
+ # `base \ {v}` within `current_type`. Walk the current
253
+ # type's union members, keep each part disjoint from
254
+ # `base`, and add the removed-value Constant once when
255
+ # any current member covers it. `assert s is
256
+ # ~non-empty-string` over `s: String | nil` narrows to
257
+ # `Constant[""] | NilClass`.
258
+ # - `IntegerRange[a, b]` (v0.0.5+ slice). Complement is
259
+ # the two open halves `int<min, a-1>` and
260
+ # `int<b+1, max>`, each intersected with the
261
+ # integer-domain parts of `current_type`. Non-integer
262
+ # parts (nil, String, …) of a Union receiver survive
263
+ # unchanged. `assert n is ~int<5, 10>` over `n:
264
+ # Integer | nil` narrows to `int<min, 4> | int<11,
265
+ # max> | NilClass`.
266
+ # - `Type::Intersection[M1, M2, …]` (v0.0.5+ slice). De
267
+ # Morgan: `D \ (M1 ∩ M2) = (D \ M1) ∪ (D \ M2)`. Each
268
+ # member's complement is computed independently within
269
+ # `current_type` and the results are unioned. Members
270
+ # the algebra cannot complement (Refined, non-Constant
271
+ # Difference, …) contribute `current_type` itself, so
272
+ # the union widens the answer to `current_type` —
273
+ # sound but imprecise.
274
+ # - `Refined[base, predicate]`. Predicate complements are
275
+ # not reducible to a finite carrier without a richer
276
+ # shape (e.g. `~lowercase-string` is "uppercase OR
277
+ # mixed-case"); `current_type` is returned unchanged.
278
+ def narrow_not_refinement(current_type, refinement_type)
279
+ case refinement_type
280
+ when Type::Difference
281
+ return current_type unless refinement_type.removed.is_a?(Type::Constant)
282
+
283
+ complement_difference(current_type, refinement_type)
284
+ when Type::IntegerRange
285
+ complement_integer_range(current_type, refinement_type)
286
+ when Type::Intersection
287
+ complement_intersection(current_type, refinement_type)
288
+ else
289
+ current_type
290
+ end
291
+ end
292
+
244
293
  # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`,
245
294
  # always; when no narrowing rule matches the predicate node both
246
295
  # entries are the receiver scope unchanged.
@@ -321,6 +370,162 @@ module Rigor
321
370
  class << self
322
371
  private
323
372
 
373
+ # Complement of `Difference[base, Constant[v]]` within
374
+ # `current_type`. Walks the current type's union members,
375
+ # keeps each member disjoint from `base` (those values
376
+ # were never in the refinement to begin with), and adds
377
+ # the removed-value `Constant[v]` exactly once when any
378
+ # current member covers it. Members that are fully
379
+ # contained in the refinement (i.e. inside `base` and
380
+ # NOT equal to the removed value) are dropped — they are
381
+ # exactly the values the negation excludes.
382
+ def complement_difference(current_type, difference)
383
+ base = difference.base
384
+ removed = difference.removed
385
+ parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
386
+
387
+ survivors = []
388
+ add_removed = false
389
+ parts.each do |part|
390
+ if base_disjoint?(base, part)
391
+ survivors << part
392
+ elsif part_covers_constant?(part, removed)
393
+ add_removed = true
394
+ end
395
+ end
396
+ survivors << removed if add_removed
397
+
398
+ return current_type if survivors.empty?
399
+
400
+ Type::Combinator.union(*survivors)
401
+ end
402
+
403
+ def base_disjoint?(base, part)
404
+ base.accepts(part, mode: :gradual).no?
405
+ end
406
+
407
+ def part_covers_constant?(part, constant)
408
+ result = part.accepts(constant, mode: :gradual)
409
+ result.yes? || result.maybe?
410
+ end
411
+
412
+ # Complement of an `IntegerRange[a, b]` within
413
+ # `current_type`. Splits the range complement into the
414
+ # two open halves `int<min, a-1>` and `int<b+1, max>`
415
+ # (skipping a half when its bound is infinity), then
416
+ # intersects each half with the integer-domain parts of
417
+ # `current_type`. Non-integer parts of a Union receiver
418
+ # (nil, String, …) survive unchanged.
419
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
420
+ def complement_integer_range(current_type, range)
421
+ halves = integer_range_complement_halves(range)
422
+ parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
423
+
424
+ survivors = []
425
+ parts.each do |part|
426
+ if integer_member?(part)
427
+ halves.each do |half|
428
+ meet = intersect_integer_part(part, half)
429
+ survivors << meet unless meet.nil? || meet.is_a?(Type::Bot)
430
+ end
431
+ else
432
+ survivors << part
433
+ end
434
+ end
435
+
436
+ return current_type if survivors.empty?
437
+
438
+ Type::Combinator.union(*survivors)
439
+ end
440
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
441
+
442
+ # Returns the two open halves of an IntegerRange's
443
+ # complement: the left half `int<-∞, a-1>` (when `a` is
444
+ # finite) and the right half `int<b+1, ∞>` (when `b` is
445
+ # finite). Universal ranges (both bounds infinite) yield
446
+ # an empty array — the complement is empty.
447
+ def integer_range_complement_halves(range)
448
+ halves = []
449
+ left_max = range.min
450
+ right_min = range.max
451
+
452
+ if left_max.is_a?(Integer)
453
+ halves << Type::Combinator.integer_range(Type::IntegerRange::NEG_INFINITY, left_max - 1)
454
+ end
455
+ if right_min.is_a?(Integer)
456
+ halves << Type::Combinator.integer_range(right_min + 1, Type::IntegerRange::POS_INFINITY)
457
+ end
458
+ halves
459
+ end
460
+
461
+ def integer_member?(part)
462
+ case part
463
+ when Type::Constant then part.value.is_a?(Integer)
464
+ when Type::IntegerRange then true
465
+ when Type::Nominal then part.class_name == "Integer"
466
+ else false
467
+ end
468
+ end
469
+
470
+ # Intersect an integer-domain part with a complement
471
+ # half-range. For a Nominal[Integer] receiver the meet
472
+ # is the half itself; for an existing IntegerRange the
473
+ # meet narrows both bounds; for a Constant[Integer] the
474
+ # meet is the constant when the half covers it,
475
+ # otherwise nil.
476
+ def intersect_integer_part(part, half)
477
+ case part
478
+ when Type::Nominal
479
+ half
480
+ when Type::IntegerRange
481
+ integer_range_meet(part, half)
482
+ when Type::Constant
483
+ half.covers?(part.value) ? part : nil
484
+ end
485
+ end
486
+
487
+ def integer_range_meet(left, right)
488
+ low = numeric_to_bound([integer_bound_value(left.min), integer_bound_value(right.min)].max)
489
+ high = numeric_to_bound([integer_bound_value(left.max), integer_bound_value(right.max)].min)
490
+ return nil if integer_range_disjoint?(low, high)
491
+
492
+ Type::Combinator.integer_range(low, high)
493
+ end
494
+
495
+ def integer_bound_value(bound)
496
+ return -Float::INFINITY if bound == Type::IntegerRange::NEG_INFINITY
497
+ return Float::INFINITY if bound == Type::IntegerRange::POS_INFINITY
498
+
499
+ bound
500
+ end
501
+
502
+ def numeric_to_bound(value)
503
+ return Type::IntegerRange::NEG_INFINITY if value == -Float::INFINITY
504
+ return Type::IntegerRange::POS_INFINITY if value == Float::INFINITY
505
+
506
+ value.to_i
507
+ end
508
+
509
+ def integer_range_disjoint?(low, high)
510
+ return false if low == Type::IntegerRange::NEG_INFINITY
511
+ return false if high == Type::IntegerRange::POS_INFINITY
512
+
513
+ low > high
514
+ end
515
+
516
+ # De Morgan: `D \ (M1 ∩ M2 ∩ …) = (D \ M1) ∪ (D \ M2) ∪
517
+ # …`. Each member's complement is computed independently
518
+ # within `current_type` and the results are unioned.
519
+ # Members the algebra cannot complement contribute
520
+ # `current_type` itself, so the union widens to
521
+ # `current_type` overall — sound but imprecise.
522
+ def complement_intersection(current_type, intersection)
523
+ per_member = intersection.members.map do |member|
524
+ Narrowing.narrow_not_refinement(current_type, member)
525
+ end
526
+ Type::Combinator.union(*per_member)
527
+ end
528
+
324
529
  def falsey_value?(value)
325
530
  value.nil? || value == false
326
531
  end
@@ -1029,7 +1234,11 @@ module Rigor
1029
1234
  # the effect's `negative?` flag. Shared between
1030
1235
  # predicate-if-* and assert-if-* application paths.
1031
1236
  def narrow_for_effect(current, effect, environment)
1032
- return effect.refinement_type if effect.respond_to?(:refinement?) && effect.refinement?
1237
+ if effect.respond_to?(:refinement?) && effect.refinement?
1238
+ return narrow_not_refinement(current, effect.refinement_type) if effect.negative?
1239
+
1240
+ return effect.refinement_type
1241
+ end
1033
1242
 
1034
1243
  if effect.negative?
1035
1244
  narrow_not_class(current, effect.class_name, exact: false, environment: environment)