rigortype 0.0.2 → 0.0.4
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.
- checksums.yaml +4 -4
- data/README.md +24 -7
- data/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/hash.yml +936 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/range.yml +389 -0
- data/data/builtins/ruby_core/set.yml +594 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/data/builtins/ruby_core/time.yml +750 -0
- data/lib/rigor/analysis/check_rules.rb +97 -4
- data/lib/rigor/analysis/runner.rb +4 -0
- data/lib/rigor/builtins/imported_refinements.rb +251 -0
- data/lib/rigor/configuration.rb +6 -1
- data/lib/rigor/inference/acceptance.rb +324 -6
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
- data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
- data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
- data/lib/rigor/inference/expression_typer.rb +48 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/method_parameter_binder.rb +29 -4
- data/lib/rigor/inference/narrowing.rb +376 -4
- data/lib/rigor/inference/scope_indexer.rb +10 -2
- data/lib/rigor/inference/statement_evaluator.rb +213 -2
- data/lib/rigor/rbs_extended.rb +230 -15
- data/lib/rigor/scope.rb +14 -0
- data/lib/rigor/type/combinator.rb +159 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type/intersection.rb +135 -0
- data/lib/rigor/type/refined.rb +174 -0
- data/lib/rigor/type.rb +4 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/rbs_extended.rbs +14 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +91 -1
- metadata +25 -1
|
@@ -6,9 +6,13 @@ require_relative "dynamic"
|
|
|
6
6
|
require_relative "nominal"
|
|
7
7
|
require_relative "singleton"
|
|
8
8
|
require_relative "constant"
|
|
9
|
+
require_relative "integer_range"
|
|
9
10
|
require_relative "tuple"
|
|
10
11
|
require_relative "hash_shape"
|
|
11
12
|
require_relative "union"
|
|
13
|
+
require_relative "difference"
|
|
14
|
+
require_relative "refined"
|
|
15
|
+
require_relative "intersection"
|
|
12
16
|
|
|
13
17
|
module Rigor
|
|
14
18
|
module Type
|
|
@@ -20,7 +24,7 @@ module Rigor
|
|
|
20
24
|
#
|
|
21
25
|
# See docs/internal-spec/internal-type-api.md and
|
|
22
26
|
# docs/type-specification/normalization.md.
|
|
23
|
-
module Combinator
|
|
27
|
+
module Combinator # rubocop:disable Metrics/ModuleLength
|
|
24
28
|
module_function
|
|
25
29
|
|
|
26
30
|
def top
|
|
@@ -65,6 +69,131 @@ module Rigor
|
|
|
65
69
|
Constant.new(value)
|
|
66
70
|
end
|
|
67
71
|
|
|
72
|
+
# Bounded-integer carrier. Each bound is either an `Integer` or
|
|
73
|
+
# one of `:neg_infinity` / `:pos_infinity` (sentinels exposed as
|
|
74
|
+
# `IntegerRange::NEG_INFINITY` / `POS_INFINITY`).
|
|
75
|
+
def integer_range(min, max)
|
|
76
|
+
IntegerRange.new(min, max)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convenience aliases for the most common bounded shapes. The
|
|
80
|
+
# named alias survives roundtrip through `describe` for nicer
|
|
81
|
+
# human-facing output.
|
|
82
|
+
def positive_int
|
|
83
|
+
IntegerRange.new(1, IntegerRange::POS_INFINITY)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def non_negative_int
|
|
87
|
+
IntegerRange.new(0, IntegerRange::POS_INFINITY)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def negative_int
|
|
91
|
+
IntegerRange.new(IntegerRange::NEG_INFINITY, -1)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def non_positive_int
|
|
95
|
+
IntegerRange.new(IntegerRange::NEG_INFINITY, 0)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def universal_int
|
|
99
|
+
IntegerRange.new(IntegerRange::NEG_INFINITY, IntegerRange::POS_INFINITY)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Point-removal refinement carrier (ADR-3 OQ3 Option C). Use
|
|
103
|
+
# `non_empty_string` / `non_zero_int` / `non_empty_array` /
|
|
104
|
+
# `non_empty_hash` for the imported built-in shapes; raw
|
|
105
|
+
# `difference(base, removed)` for ad-hoc refinements an
|
|
106
|
+
# `RBS::Extended` annotation introduces.
|
|
107
|
+
def difference(base, removed)
|
|
108
|
+
Difference.new(base, removed)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def non_empty_string
|
|
112
|
+
Difference.new(nominal_of("String"), constant_of(""))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def non_zero_int
|
|
116
|
+
Difference.new(nominal_of("Integer"), constant_of(0))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# `non-empty-array[T]` requires the element type so the
|
|
120
|
+
# `Nominal[Array, [T]]` projection through Array#first /
|
|
121
|
+
# #last keeps element precision intact. The default
|
|
122
|
+
# `Top` admits any array element when the caller does
|
|
123
|
+
# not have a more specific element type.
|
|
124
|
+
def non_empty_array(element = top)
|
|
125
|
+
Difference.new(
|
|
126
|
+
nominal_of("Array", type_args: [element]),
|
|
127
|
+
tuple_of
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def non_empty_hash(key = top, value = top)
|
|
132
|
+
Difference.new(
|
|
133
|
+
nominal_of("Hash", type_args: [key, value]),
|
|
134
|
+
hash_shape_of({})
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Predicate-subset refinement carrier (ADR-3 OQ3 Option C,
|
|
139
|
+
# second half). Use `lowercase_string` /
|
|
140
|
+
# `uppercase_string` / `numeric_string` for the imported
|
|
141
|
+
# built-in shapes; raw `refined(base, predicate_id)` for
|
|
142
|
+
# ad-hoc refinements introduced by an `RBS::Extended`
|
|
143
|
+
# annotation or a plugin-contributed predicate.
|
|
144
|
+
def refined(base, predicate_id)
|
|
145
|
+
Refined.new(base, predicate_id)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def lowercase_string
|
|
149
|
+
Refined.new(nominal_of("String"), :lowercase)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def uppercase_string
|
|
153
|
+
Refined.new(nominal_of("String"), :uppercase)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def numeric_string
|
|
157
|
+
Refined.new(nominal_of("String"), :numeric)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def decimal_int_string
|
|
161
|
+
Refined.new(nominal_of("String"), :decimal_int)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def octal_int_string
|
|
165
|
+
Refined.new(nominal_of("String"), :octal_int)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def hex_int_string
|
|
169
|
+
Refined.new(nominal_of("String"), :hex_int)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Normalised intersection. Flattens nested Intersections,
|
|
173
|
+
# drops `Top` members, collapses to `Bot` if any member is
|
|
174
|
+
# `Bot`, deduplicates structurally-equal members, sorts the
|
|
175
|
+
# survivors by `describe(:short)`, and collapses 0-/1-member
|
|
176
|
+
# results so a degenerate intersection never reaches the
|
|
177
|
+
# carrier. See ADR-3 OQ3 for the rationale; the lattice
|
|
178
|
+
# algebra is in
|
|
179
|
+
# [`value-lattice.md`](docs/type-specification/value-lattice.md).
|
|
180
|
+
def intersection(*members)
|
|
181
|
+
collapse_intersection(normalised_intersection_members(members))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# `non-empty-lowercase-string` = non-empty-string ∩
|
|
185
|
+
# lowercase-string. Composes the point-removal half
|
|
186
|
+
# (`Difference[String, ""]`) with the predicate-subset half
|
|
187
|
+
# (`Refined[String, :lowercase]`). Both members erase to
|
|
188
|
+
# `String` so the carrier's RBS erasure is unambiguous.
|
|
189
|
+
def non_empty_lowercase_string
|
|
190
|
+
intersection(non_empty_string, lowercase_string)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def non_empty_uppercase_string
|
|
194
|
+
intersection(non_empty_string, uppercase_string)
|
|
195
|
+
end
|
|
196
|
+
|
|
68
197
|
# Constructs a heterogeneous, fixed-arity Tuple from positional
|
|
69
198
|
# element types. `tuple_of()` produces the empty tuple `Tuple[]`,
|
|
70
199
|
# which is structurally distinct from the raw `Nominal[Array]`.
|
|
@@ -117,6 +246,35 @@ module Rigor
|
|
|
117
246
|
end
|
|
118
247
|
end
|
|
119
248
|
|
|
249
|
+
# Symmetric counterparts to the Union normalisers. The
|
|
250
|
+
# absorbing element is `Bot` (anything intersected with
|
|
251
|
+
# nothing is nothing) and the identity element is `Top`
|
|
252
|
+
# (intersecting with the universal type is a no-op).
|
|
253
|
+
def normalised_intersection_members(types)
|
|
254
|
+
flattened = []
|
|
255
|
+
types.each { |t| flatten_intersection_into(flattened, t) }
|
|
256
|
+
return [bot] if flattened.any?(Bot)
|
|
257
|
+
|
|
258
|
+
flattened.reject! { |t| t.is_a?(Top) }
|
|
259
|
+
unique_members(flattened)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def collapse_intersection(types)
|
|
263
|
+
case types.size
|
|
264
|
+
when 0 then top
|
|
265
|
+
when 1 then types.first
|
|
266
|
+
else Intersection.new(sort_members(types))
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def flatten_intersection_into(acc, type)
|
|
271
|
+
if type.is_a?(Intersection)
|
|
272
|
+
type.members.each { |m| flatten_intersection_into(acc, m) }
|
|
273
|
+
else
|
|
274
|
+
acc << type
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
120
278
|
def resolve_class_name(class_name_or_object)
|
|
121
279
|
name =
|
|
122
280
|
case class_name_or_object
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Type
|
|
7
|
+
# `Difference[base, removed]` — the value set of `base` minus
|
|
8
|
+
# the value set of `removed`. Implements the point-removal
|
|
9
|
+
# half of the OQ3 refinement-carrier strategy
|
|
10
|
+
# ([ADR-3](docs/adr/3-type-representation.md), Working
|
|
11
|
+
# Decision Option C):
|
|
12
|
+
#
|
|
13
|
+
# non-empty-string = Difference[Nominal[String], Constant[""]]
|
|
14
|
+
# non-zero-int = Difference[Nominal[Integer], Constant[0]]
|
|
15
|
+
# non-empty-array[T] = Difference[Nominal[Array, [T]], Tuple[]]
|
|
16
|
+
# non-empty-hash[K,V] = Difference[Nominal[Hash, [K,V]], HashShape{}]
|
|
17
|
+
#
|
|
18
|
+
# The carrier itself is structural: it stores `base` and
|
|
19
|
+
# `removed` as inner `Type` references and answers projection
|
|
20
|
+
# / acceptance / display questions by composing those inner
|
|
21
|
+
# answers per the lattice algebra in
|
|
22
|
+
# [`value-lattice.md`](docs/type-specification/value-lattice.md).
|
|
23
|
+
# The canonical-name registry (display side) lives in
|
|
24
|
+
# `Rigor::Type::Combinator` and prints kebab-case names like
|
|
25
|
+
# `non-empty-string` for the recognised shapes; unrecognised
|
|
26
|
+
# differences fall back to the raw `base - removed`
|
|
27
|
+
# operator form per [`type-operators.md`](docs/type-specification/type-operators.md).
|
|
28
|
+
#
|
|
29
|
+
# Construction goes through `Type::Combinator.difference` /
|
|
30
|
+
# `Combinator.non_empty_string` etc. — direct `.new` calls
|
|
31
|
+
# are an internal contract; callers MUST ensure both bounds
|
|
32
|
+
# are valid `Rigor::Type` values and that `removed` is a
|
|
33
|
+
# subtype-or-equal of `base` (otherwise the difference does
|
|
34
|
+
# not narrow anything and a normalisation upstream should
|
|
35
|
+
# collapse to `base`).
|
|
36
|
+
class Difference
|
|
37
|
+
attr_reader :base, :removed
|
|
38
|
+
|
|
39
|
+
def initialize(base, removed)
|
|
40
|
+
@base = base
|
|
41
|
+
@removed = removed
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def describe(verbosity = :short)
|
|
46
|
+
named = canonical_name
|
|
47
|
+
return named if named
|
|
48
|
+
|
|
49
|
+
"#{base.describe(verbosity)} - #{removed.describe(verbosity)}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Erases to the base nominal: every refinement MUST erase
|
|
53
|
+
# to its base per [`rbs-erasure.md`](docs/type-specification/rbs-erasure.md).
|
|
54
|
+
def erase_to_rbs
|
|
55
|
+
base.erase_to_rbs
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def top
|
|
59
|
+
Trinary.no
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def bot
|
|
63
|
+
Trinary.no
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dynamic
|
|
67
|
+
base.respond_to?(:dynamic) ? base.dynamic : Trinary.no
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def accepts(other, mode: :gradual)
|
|
71
|
+
Inference::Acceptance.accepts(self, other, mode: mode)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ==(other)
|
|
75
|
+
other.is_a?(Difference) && base == other.base && removed == other.removed
|
|
76
|
+
end
|
|
77
|
+
alias eql? ==
|
|
78
|
+
|
|
79
|
+
def hash
|
|
80
|
+
[Difference, base, removed].hash
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inspect
|
|
84
|
+
"#<Rigor::Type::Difference #{describe(:short)}>"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Renders the kebab-case shorthand for recognised
|
|
90
|
+
# imported-built-in shapes. Parameterised bases keep their
|
|
91
|
+
# type-args in the canonical form (`non-empty-array[T]`,
|
|
92
|
+
# `non-empty-hash[K, V]`) so element-precision survives the
|
|
93
|
+
# display round-trip. Unrecognised shapes fall back to the
|
|
94
|
+
# raw `base - removed` operator form.
|
|
95
|
+
#
|
|
96
|
+
# The recognised set is kept in sync with the imported-built-in
|
|
97
|
+
# catalogue ([`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md)).
|
|
98
|
+
def canonical_name
|
|
99
|
+
return nil unless base.is_a?(Nominal)
|
|
100
|
+
|
|
101
|
+
send(CANONICAL_HANDLERS[base.class_name] || :no_canonical_name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
CANONICAL_HANDLERS = {
|
|
105
|
+
"String" => :string_canonical_name,
|
|
106
|
+
"Integer" => :integer_canonical_name,
|
|
107
|
+
"Array" => :array_canonical_name_if_empty,
|
|
108
|
+
"Hash" => :hash_canonical_name_if_empty
|
|
109
|
+
}.freeze
|
|
110
|
+
private_constant :CANONICAL_HANDLERS
|
|
111
|
+
|
|
112
|
+
def no_canonical_name
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def string_canonical_name
|
|
117
|
+
return nil unless removed.is_a?(Constant) && removed.value == ""
|
|
118
|
+
|
|
119
|
+
"non-empty-string"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def integer_canonical_name
|
|
123
|
+
return nil unless removed.is_a?(Constant) && removed.value.is_a?(Integer) && removed.value.zero?
|
|
124
|
+
|
|
125
|
+
"non-zero-int"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def array_canonical_name_if_empty
|
|
129
|
+
return nil unless removed.is_a?(Tuple) && removed.elements.empty?
|
|
130
|
+
|
|
131
|
+
array_canonical_name
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def hash_canonical_name_if_empty
|
|
135
|
+
return nil unless removed.is_a?(HashShape) && removed.pairs.empty?
|
|
136
|
+
|
|
137
|
+
hash_canonical_name
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def array_canonical_name
|
|
141
|
+
elem = base.type_args.first
|
|
142
|
+
return "non-empty-array" if elem.nil?
|
|
143
|
+
|
|
144
|
+
"non-empty-array[#{elem.describe}]"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def hash_canonical_name
|
|
148
|
+
key, value = base.type_args
|
|
149
|
+
return "non-empty-hash" if key.nil? || value.nil?
|
|
150
|
+
|
|
151
|
+
"non-empty-hash[#{key.describe}, #{value.describe}]"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Type
|
|
7
|
+
# A bounded integer range carrier. Each bound is either an `Integer`
|
|
8
|
+
# or one of the symbolic infinities `:neg_infinity` / `:pos_infinity`.
|
|
9
|
+
# Inspired by PHPStan's `int<min, max>` family — the named aliases
|
|
10
|
+
# `positive-int` (1..), `non-negative-int` (0..), `negative-int`
|
|
11
|
+
# (..-1), `non-positive-int` (..0) all surface through this single
|
|
12
|
+
# carrier and are recovered in `describe` for human-friendly output.
|
|
13
|
+
#
|
|
14
|
+
# Constraints on construction:
|
|
15
|
+
# - both bounds must be either `Integer` or one of the two infinity
|
|
16
|
+
# sentinels;
|
|
17
|
+
# - if both bounds are concrete, `min <= max` must hold;
|
|
18
|
+
# - the universal case `(-∞, +∞)` is structurally distinct from
|
|
19
|
+
# `Nominal[Integer]` — it carries no extra information today but
|
|
20
|
+
# keeps the carrier closed under range narrowing.
|
|
21
|
+
#
|
|
22
|
+
# Erasure to RBS is always "Integer": RBS itself does not natively
|
|
23
|
+
# express bounded integer ranges.
|
|
24
|
+
class IntegerRange
|
|
25
|
+
NEG_INFINITY = :neg_infinity
|
|
26
|
+
POS_INFINITY = :pos_infinity
|
|
27
|
+
INFINITIES = [NEG_INFINITY, POS_INFINITY].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :min, :max
|
|
30
|
+
|
|
31
|
+
def initialize(min, max)
|
|
32
|
+
validate_bound!(min, "min")
|
|
33
|
+
validate_bound!(max, "max")
|
|
34
|
+
if min.is_a?(Integer) && max.is_a?(Integer) && min > max
|
|
35
|
+
raise ArgumentError, "IntegerRange requires min (#{min}) <= max (#{max})"
|
|
36
|
+
end
|
|
37
|
+
if min == POS_INFINITY || max == NEG_INFINITY
|
|
38
|
+
raise ArgumentError, "IntegerRange bounds out of order: min=#{min.inspect}, max=#{max.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@min = min
|
|
42
|
+
@max = max
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def universal?
|
|
47
|
+
min == NEG_INFINITY && max == POS_INFINITY
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finite?
|
|
51
|
+
min.is_a?(Integer) && max.is_a?(Integer)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cardinality
|
|
55
|
+
finite? ? (max - min + 1) : Float::INFINITY
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def covers?(int)
|
|
59
|
+
return false unless int.is_a?(Integer)
|
|
60
|
+
|
|
61
|
+
int.between?(lower, upper)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the lower bound as a numeric (with `-Float::INFINITY` for
|
|
65
|
+
# `:neg_infinity`). Use this in arithmetic comparisons; never compare
|
|
66
|
+
# `:neg_infinity` directly with an `Integer`.
|
|
67
|
+
def lower
|
|
68
|
+
min == NEG_INFINITY ? -Float::INFINITY : min
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def upper
|
|
72
|
+
max == POS_INFINITY ? Float::INFINITY : max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
ALIAS_NAMES = {
|
|
76
|
+
[NEG_INFINITY, POS_INFINITY] => "int",
|
|
77
|
+
[1, POS_INFINITY] => "positive-int",
|
|
78
|
+
[0, POS_INFINITY] => "non-negative-int",
|
|
79
|
+
[NEG_INFINITY, -1] => "negative-int",
|
|
80
|
+
[NEG_INFINITY, 0] => "non-positive-int"
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
def describe(_verbosity = :short)
|
|
84
|
+
ALIAS_NAMES[[min, max]] || generic_description
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def generic_description
|
|
88
|
+
return "int<#{min}, max>" if max == POS_INFINITY
|
|
89
|
+
return "int<min, #{max}>" if min == NEG_INFINITY
|
|
90
|
+
|
|
91
|
+
"int<#{min}, #{max}>"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def erase_to_rbs
|
|
95
|
+
"Integer"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def top
|
|
99
|
+
Trinary.no
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def bot
|
|
103
|
+
Trinary.no
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def dynamic
|
|
107
|
+
Trinary.no
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def accepts(other, mode: :gradual)
|
|
111
|
+
Inference::Acceptance.accepts(self, other, mode: mode)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ==(other)
|
|
115
|
+
other.is_a?(IntegerRange) && min == other.min && max == other.max
|
|
116
|
+
end
|
|
117
|
+
alias eql? ==
|
|
118
|
+
|
|
119
|
+
def hash
|
|
120
|
+
[IntegerRange, min, max].hash
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def inspect
|
|
124
|
+
"#<Rigor::Type::IntegerRange #{describe(:short)}>"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def validate_bound!(bound, label)
|
|
130
|
+
return if bound.is_a?(Integer) || INFINITIES.include?(bound)
|
|
131
|
+
|
|
132
|
+
raise ArgumentError,
|
|
133
|
+
"IntegerRange #{label} must be Integer or :neg_infinity/:pos_infinity, got #{bound.inspect}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Type
|
|
7
|
+
# `Intersection[M1, M2, …]` — value set is the meet of every
|
|
8
|
+
# member's value set. The carrier composes refinements that
|
|
9
|
+
# share a base, in particular the catalogued
|
|
10
|
+
# `non-empty-lowercase-string` (= `Difference[String, ""] &
|
|
11
|
+
# Refined[String, :lowercase]`) and
|
|
12
|
+
# `non-empty-uppercase-string` shapes from
|
|
13
|
+
# [`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md).
|
|
14
|
+
# See [ADR-3](docs/adr/3-type-representation.md) for the
|
|
15
|
+
# OQ3 working decision and the rationale for keeping
|
|
16
|
+
# Intersection a thin wrapper rather than per-shape carriers.
|
|
17
|
+
#
|
|
18
|
+
# Construction MUST go through `Type::Combinator.intersection`
|
|
19
|
+
# (or the per-name factories
|
|
20
|
+
# `Combinator.non_empty_lowercase_string` /
|
|
21
|
+
# `Combinator.non_empty_uppercase_string`). The factory:
|
|
22
|
+
#
|
|
23
|
+
# - flattens nested intersections,
|
|
24
|
+
# - drops `Top` members (Top is the identity of intersection),
|
|
25
|
+
# - collapses to `Bot` if any member is `Bot` (Bot is absorbing),
|
|
26
|
+
# - deduplicates structurally-equal members,
|
|
27
|
+
# - sorts the surviving members by `describe(:short)` so two
|
|
28
|
+
# structurally-equal intersections built in different orders
|
|
29
|
+
# compare equal,
|
|
30
|
+
# - returns `Top` for the empty intersection,
|
|
31
|
+
# - returns the lone member for a 1-element intersection (so
|
|
32
|
+
# the carrier is never inhabited by a degenerate single-member
|
|
33
|
+
# shape).
|
|
34
|
+
#
|
|
35
|
+
# Direct `.new` callers MUST pass an already-normalised member
|
|
36
|
+
# list and are expected to be tests or the combinator itself.
|
|
37
|
+
class Intersection
|
|
38
|
+
attr_reader :members
|
|
39
|
+
|
|
40
|
+
def initialize(members)
|
|
41
|
+
@members = members.dup.freeze
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def describe(verbosity = :short)
|
|
46
|
+
named = canonical_name
|
|
47
|
+
return named if named
|
|
48
|
+
|
|
49
|
+
members.map { |m| m.describe(verbosity) }.join(" & ")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# An intersection of refinements over the same base type
|
|
53
|
+
# erases to that base. We use the first member's erasure
|
|
54
|
+
# because the v0.0.4 catalogue (`non-empty-lowercase-string`
|
|
55
|
+
# etc.) is restricted to same-base composition; richer
|
|
56
|
+
# cross-base intersections will need a stricter erasure
|
|
57
|
+
# rule (likely "lowest common ancestor" via the inference
|
|
58
|
+
# engine's class hierarchy).
|
|
59
|
+
def erase_to_rbs
|
|
60
|
+
members.first.erase_to_rbs
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def top
|
|
64
|
+
Trinary.no
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bot
|
|
68
|
+
Trinary.no
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def dynamic
|
|
72
|
+
Trinary.no
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def accepts(other, mode: :gradual)
|
|
76
|
+
Inference::Acceptance.accepts(self, other, mode: mode)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ==(other)
|
|
80
|
+
other.is_a?(Intersection) && members == other.members
|
|
81
|
+
end
|
|
82
|
+
alias eql? ==
|
|
83
|
+
|
|
84
|
+
def hash
|
|
85
|
+
[Intersection, members].hash
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inspect
|
|
89
|
+
"#<Rigor::Type::Intersection #{describe(:short)}>"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Maps a structurally-recognised composite shape to its
|
|
95
|
+
# kebab-case canonical name. The recognised set is kept in
|
|
96
|
+
# sync with the imported-built-in catalogue
|
|
97
|
+
# ([`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md)).
|
|
98
|
+
#
|
|
99
|
+
# Detection is order-independent — `Combinator.intersection`
|
|
100
|
+
# sorts the canonical member list, but reading the registry
|
|
101
|
+
# the other way around (a user-authored Intersection built
|
|
102
|
+
# in any order) MUST still print in its canonical spelling.
|
|
103
|
+
def canonical_name
|
|
104
|
+
return nil unless members.size == 2
|
|
105
|
+
|
|
106
|
+
bases = members.map { |m| canonical_role(m) }.compact
|
|
107
|
+
return nil unless bases.size == 2
|
|
108
|
+
|
|
109
|
+
roles = bases.sort
|
|
110
|
+
case roles
|
|
111
|
+
when %w[lowercase non_empty_string] then "non-empty-lowercase-string"
|
|
112
|
+
when %w[non_empty_string uppercase] then "non-empty-uppercase-string"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a stable role tag for the recognised composite
|
|
117
|
+
# members so `canonical_name` can pattern-match on a sorted
|
|
118
|
+
# role pair regardless of construction order. Returns nil
|
|
119
|
+
# when the member is not part of any catalogued composite —
|
|
120
|
+
# any nil contribution disqualifies the canonical-name path
|
|
121
|
+
# and the operator-form fallback kicks in.
|
|
122
|
+
def canonical_role(member)
|
|
123
|
+
case member
|
|
124
|
+
when Difference
|
|
125
|
+
"non_empty_string" if member == Type::Combinator.non_empty_string
|
|
126
|
+
when Refined
|
|
127
|
+
case member
|
|
128
|
+
when Type::Combinator.lowercase_string then "lowercase"
|
|
129
|
+
when Type::Combinator.uppercase_string then "uppercase"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|