rigortype 0.0.3 → 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/hash.yml +936 -0
- data/data/builtins/ruby_core/range.yml +389 -0
- data/data/builtins/ruby_core/set.yml +594 -0
- data/data/builtins/ruby_core/time.yml +750 -0
- data/lib/rigor/analysis/check_rules.rb +11 -3
- data/lib/rigor/builtins/imported_refinements.rb +192 -10
- data/lib/rigor/inference/acceptance.rb +181 -12
- data/lib/rigor/inference/builtins/hash_catalog.rb +40 -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/time_catalog.rb +64 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +28 -8
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +103 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +135 -6
- data/lib/rigor/inference/method_parameter_binder.rb +29 -4
- data/lib/rigor/inference/narrowing.rb +2 -0
- data/lib/rigor/inference/statement_evaluator.rb +2 -0
- data/lib/rigor/rbs_extended.rb +167 -16
- data/lib/rigor/type/combinator.rb +90 -0
- data/lib/rigor/type/intersection.rb +135 -0
- data/lib/rigor/type/refined.rb +174 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/rbs_extended.rbs +11 -0
- data/sig/rigor/type.rbs +40 -0
- metadata +11 -1
data/lib/rigor/rbs_extended.rb
CHANGED
|
@@ -50,10 +50,19 @@ module Rigor
|
|
|
50
50
|
# when the directive uses the `~ClassName` form, in
|
|
51
51
|
# which case the engine narrows AWAY from `class_name`
|
|
52
52
|
# (`Narrowing.narrow_not_class`) instead of toward it.
|
|
53
|
-
|
|
53
|
+
#
|
|
54
|
+
# `refinement_type` is non-nil when the right-hand side is
|
|
55
|
+
# a kebab-case refinement name (`non-empty-string`,
|
|
56
|
+
# `lowercase-string`, …) instead of a Capitalised class
|
|
57
|
+
# name. The narrowing tier substitutes the carrier for the
|
|
58
|
+
# current local type; `class_name` is then nil and
|
|
59
|
+
# `negative` is false (refinement-form directives do not
|
|
60
|
+
# support `~T` negation in v0.0.4).
|
|
61
|
+
PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
|
|
54
62
|
def truthy_only? = edge == :truthy_only
|
|
55
63
|
def falsey_only? = edge == :falsey_only
|
|
56
64
|
def negative? = negative == true
|
|
65
|
+
def refinement? = !refinement_type.nil?
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
# Returned for `assert` / `assert-if-true` /
|
|
@@ -71,11 +80,12 @@ module Rigor
|
|
|
71
80
|
#
|
|
72
81
|
# `negative` mirrors `PredicateEffect`: true when the
|
|
73
82
|
# directive uses `~ClassName` syntax.
|
|
74
|
-
AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative) do
|
|
83
|
+
AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
|
|
75
84
|
def always? = condition == :always
|
|
76
85
|
def if_truthy_return? = condition == :if_truthy_return
|
|
77
86
|
def if_falsey_return? = condition == :if_falsey_return
|
|
78
87
|
def negative? = negative == true
|
|
88
|
+
def refinement? = !refinement_type.nil?
|
|
79
89
|
end
|
|
80
90
|
|
|
81
91
|
module_function
|
|
@@ -100,14 +110,26 @@ module Rigor
|
|
|
100
110
|
effects.uniq
|
|
101
111
|
end
|
|
102
112
|
|
|
113
|
+
# The right-hand side accepts either a Capitalised class
|
|
114
|
+
# name (with optional `~` negation, optional `::` prefix,
|
|
115
|
+
# qualified names) OR a kebab-case refinement payload
|
|
116
|
+
# routed through `Builtins::ImportedRefinements::Parser`
|
|
117
|
+
# (bare names, `name[T]`, `name<min, max>`). The two arms
|
|
118
|
+
# share the same overall directive shape; the parser
|
|
119
|
+
# detects which form matched by looking at the `class_name`
|
|
120
|
+
# vs `refinement` capture groups.
|
|
103
121
|
PREDICATE_DIRECTIVE_PATTERN = /
|
|
104
122
|
\A
|
|
105
123
|
rigor:v1:(?<directive>predicate-if-(?:true|false))
|
|
106
124
|
\s+
|
|
107
125
|
(?<target>self|[a-z_][a-zA-Z0-9_]*)
|
|
108
126
|
\s+is\s+
|
|
109
|
-
(
|
|
110
|
-
|
|
127
|
+
(?:
|
|
128
|
+
(?<negation>~?)
|
|
129
|
+
(?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
|
|
130
|
+
|
|
|
131
|
+
(?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
|
|
132
|
+
)
|
|
111
133
|
\s*
|
|
112
134
|
\z
|
|
113
135
|
/x
|
|
@@ -119,16 +141,18 @@ module Rigor
|
|
|
119
141
|
|
|
120
142
|
directive = match[:directive].to_s
|
|
121
143
|
target = match[:target].to_s
|
|
122
|
-
class_name = match[:class_name].to_s.sub(/\A::/, "")
|
|
123
144
|
edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
|
|
124
|
-
target_kind = target
|
|
125
|
-
|
|
145
|
+
target_kind, target_name = target_fields(target)
|
|
146
|
+
class_name, refinement_type, negative = resolve_directive_rhs(match)
|
|
147
|
+
return nil if class_name.nil? && refinement_type.nil?
|
|
148
|
+
|
|
126
149
|
PredicateEffect.new(
|
|
127
150
|
edge: edge,
|
|
128
151
|
target_kind: target_kind,
|
|
129
152
|
target_name: target_name,
|
|
130
153
|
class_name: class_name,
|
|
131
|
-
negative:
|
|
154
|
+
negative: negative,
|
|
155
|
+
refinement_type: refinement_type
|
|
132
156
|
)
|
|
133
157
|
end
|
|
134
158
|
|
|
@@ -157,8 +181,12 @@ module Rigor
|
|
|
157
181
|
\s+
|
|
158
182
|
(?<target>self|[a-z_][a-zA-Z0-9_]*)
|
|
159
183
|
\s+is\s+
|
|
160
|
-
(
|
|
161
|
-
|
|
184
|
+
(?:
|
|
185
|
+
(?<negation>~?)
|
|
186
|
+
(?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
|
|
187
|
+
|
|
|
188
|
+
(?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
|
|
189
|
+
)
|
|
162
190
|
\s*
|
|
163
191
|
\z
|
|
164
192
|
/x
|
|
@@ -180,18 +208,55 @@ module Rigor
|
|
|
180
208
|
return nil if condition.nil?
|
|
181
209
|
|
|
182
210
|
target = match[:target].to_s
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
target_kind, target_name = target_fields(target)
|
|
212
|
+
class_name, refinement_type, negative = resolve_directive_rhs(match)
|
|
213
|
+
return nil if class_name.nil? && refinement_type.nil?
|
|
214
|
+
|
|
186
215
|
AssertEffect.new(
|
|
187
216
|
condition: condition,
|
|
188
217
|
target_kind: target_kind,
|
|
189
218
|
target_name: target_name,
|
|
190
219
|
class_name: class_name,
|
|
191
|
-
negative:
|
|
220
|
+
negative: negative,
|
|
221
|
+
refinement_type: refinement_type
|
|
192
222
|
)
|
|
193
223
|
end
|
|
194
224
|
|
|
225
|
+
# Resolves the `class_name` / `refinement` alternation in
|
|
226
|
+
# the assert / predicate directive patterns. Returns
|
|
227
|
+
# `[class_name, refinement_type, negative]`:
|
|
228
|
+
#
|
|
229
|
+
# - Class-name arm matched: `class_name` is the resolved
|
|
230
|
+
# string (leading `::` stripped), `refinement_type` is
|
|
231
|
+
# nil, `negative` reflects the optional `~` prefix.
|
|
232
|
+
# - Refinement arm matched: `class_name` is nil,
|
|
233
|
+
# `refinement_type` is the resolved `Rigor::Type`,
|
|
234
|
+
# `negative` is `false` (refinement-form directives do
|
|
235
|
+
# not support `~` negation in v0.0.4).
|
|
236
|
+
# - Refinement payload unparseable: returns
|
|
237
|
+
# `[nil, nil, false]` so callers can drop the directive
|
|
238
|
+
# silently (fail-soft policy).
|
|
239
|
+
def resolve_directive_rhs(match)
|
|
240
|
+
class_capture = match[:class_name]
|
|
241
|
+
return [class_capture.to_s.sub(/\A::/, ""), nil, match[:negation].to_s == "~"] if class_capture
|
|
242
|
+
|
|
243
|
+
refinement_capture = match[:refinement]
|
|
244
|
+
return [nil, nil, false] if refinement_capture.nil?
|
|
245
|
+
|
|
246
|
+
type = Builtins::ImportedRefinements.parse(refinement_capture)
|
|
247
|
+
return [nil, nil, false] if type.nil?
|
|
248
|
+
|
|
249
|
+
[nil, type, false]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def target_fields(target)
|
|
253
|
+
if target == "self"
|
|
254
|
+
%i[self self]
|
|
255
|
+
else
|
|
256
|
+
[:parameter, target.to_sym]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
195
260
|
# Reads the `rigor:v1:return: <kebab-name>` directive off
|
|
196
261
|
# `RBS::Definition::Method#annotations`. The directive
|
|
197
262
|
# overrides a method's RBS-declared return type with one of
|
|
@@ -238,11 +303,20 @@ module Rigor
|
|
|
238
303
|
nil
|
|
239
304
|
end
|
|
240
305
|
|
|
306
|
+
# The trailing payload supports the full refinement
|
|
307
|
+
# grammar in `Builtins::ImportedRefinements::Parser` —
|
|
308
|
+
# bare kebab-case names plus parameterised forms like
|
|
309
|
+
# `non-empty-array[Integer]`, `non-empty-hash[Symbol,
|
|
310
|
+
# Integer]`, and `int<5, 10>`. The directive head is
|
|
311
|
+
# consumed by the regex; the rest is forwarded to the
|
|
312
|
+
# refinement parser. Anything the parser cannot resolve
|
|
313
|
+
# falls back to nil so the call site keeps the
|
|
314
|
+
# RBS-declared return type.
|
|
241
315
|
RETURN_DIRECTIVE_PATTERN = /
|
|
242
316
|
\A
|
|
243
317
|
rigor:v1:return:
|
|
244
318
|
\s+
|
|
245
|
-
(?<
|
|
319
|
+
(?<payload>\S(?:.*\S)?)
|
|
246
320
|
\s*
|
|
247
321
|
\z
|
|
248
322
|
/x
|
|
@@ -252,7 +326,84 @@ module Rigor
|
|
|
252
326
|
match = RETURN_DIRECTIVE_PATTERN.match(string)
|
|
253
327
|
return nil if match.nil?
|
|
254
328
|
|
|
255
|
-
Builtins::ImportedRefinements.
|
|
329
|
+
Builtins::ImportedRefinements.parse(match[:payload])
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Returned for `rigor:v1:param: <name> <refinement>`. The
|
|
333
|
+
# parameter name is a Ruby identifier (Symbol); the type
|
|
334
|
+
# is any `Rigor::Type` the refinement parser resolves
|
|
335
|
+
# (bare kebab-case name, parameterised form, or `int<...>`
|
|
336
|
+
# range — the same grammar the `return:` directive
|
|
337
|
+
# accepts).
|
|
338
|
+
ParamOverride = Data.define(:param_name, :type)
|
|
339
|
+
|
|
340
|
+
# Reads every `rigor:v1:param: <name> <refinement>`
|
|
341
|
+
# directive off `RBS::Definition::Method#annotations` and
|
|
342
|
+
# returns the resolved `ParamOverride` list. Annotations
|
|
343
|
+
# the parser cannot resolve (typo, unknown refinement, no
|
|
344
|
+
# `param:` directive at all) are silently dropped — the
|
|
345
|
+
# call site keeps the RBS-declared parameter type for
|
|
346
|
+
# those parameters. The reader accepts a nil method
|
|
347
|
+
# definition so call sites can pass through optional
|
|
348
|
+
# method lookups without a guard.
|
|
349
|
+
#
|
|
350
|
+
# Example annotation in an RBS file:
|
|
351
|
+
#
|
|
352
|
+
# class Slug
|
|
353
|
+
# %a{rigor:v1:param: id is non-empty-string}
|
|
354
|
+
# def normalise: (::String id) -> String
|
|
355
|
+
# end
|
|
356
|
+
#
|
|
357
|
+
# The RBS-declared type of `id` is `String`. The override
|
|
358
|
+
# tightens it to `non-empty-string` for argument-check
|
|
359
|
+
# purposes; passing a too-wide `Nominal[String]` argument
|
|
360
|
+
# is flagged as an argument-type mismatch at the call
|
|
361
|
+
# site.
|
|
362
|
+
def read_param_type_overrides(method_def)
|
|
363
|
+
return [] if method_def.nil?
|
|
364
|
+
|
|
365
|
+
annotations = method_def.annotations
|
|
366
|
+
return [] if annotations.nil? || annotations.empty?
|
|
367
|
+
|
|
368
|
+
annotations.filter_map { |annotation| parse_param_annotation(annotation.string) }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Convenience reader for call sites that want to look up
|
|
372
|
+
# a single override by parameter name. Returns a frozen
|
|
373
|
+
# Hash<Symbol, Rigor::Type>; missing keys mean "use the
|
|
374
|
+
# RBS-declared type". Callers MUST treat the hash as
|
|
375
|
+
# read-only.
|
|
376
|
+
def param_type_override_map(method_def)
|
|
377
|
+
read_param_type_overrides(method_def).to_h { |o| [o.param_name, o.type] }.freeze
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# The `is` glue word is optional so authors can write
|
|
381
|
+
# either `param: id is non-empty-string` (consistent with
|
|
382
|
+
# the existing `assert` / `predicate-if-*` directives) or
|
|
383
|
+
# the terser `param: id non-empty-string`. The trailing
|
|
384
|
+
# payload accepts the full refinement grammar in
|
|
385
|
+
# `Builtins::ImportedRefinements::Parser`.
|
|
386
|
+
PARAM_DIRECTIVE_PATTERN = /
|
|
387
|
+
\A
|
|
388
|
+
rigor:v1:param:
|
|
389
|
+
\s+
|
|
390
|
+
(?<param>[a-z_][a-zA-Z0-9_]*)
|
|
391
|
+
\s+
|
|
392
|
+
(?:is\s+)?
|
|
393
|
+
(?<payload>\S(?:.*\S)?)
|
|
394
|
+
\s*
|
|
395
|
+
\z
|
|
396
|
+
/x
|
|
397
|
+
private_constant :PARAM_DIRECTIVE_PATTERN
|
|
398
|
+
|
|
399
|
+
def parse_param_annotation(string)
|
|
400
|
+
match = PARAM_DIRECTIVE_PATTERN.match(string)
|
|
401
|
+
return nil if match.nil?
|
|
402
|
+
|
|
403
|
+
type = Builtins::ImportedRefinements.parse(match[:payload])
|
|
404
|
+
return nil if type.nil?
|
|
405
|
+
|
|
406
|
+
ParamOverride.new(param_name: match[:param].to_sym, type: type)
|
|
256
407
|
end
|
|
257
408
|
end
|
|
258
409
|
end
|
|
@@ -11,6 +11,8 @@ require_relative "tuple"
|
|
|
11
11
|
require_relative "hash_shape"
|
|
12
12
|
require_relative "union"
|
|
13
13
|
require_relative "difference"
|
|
14
|
+
require_relative "refined"
|
|
15
|
+
require_relative "intersection"
|
|
14
16
|
|
|
15
17
|
module Rigor
|
|
16
18
|
module Type
|
|
@@ -133,6 +135,65 @@ module Rigor
|
|
|
133
135
|
)
|
|
134
136
|
end
|
|
135
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
|
+
|
|
136
197
|
# Constructs a heterogeneous, fixed-arity Tuple from positional
|
|
137
198
|
# element types. `tuple_of()` produces the empty tuple `Tuple[]`,
|
|
138
199
|
# which is structurally distinct from the raw `Nominal[Array]`.
|
|
@@ -185,6 +246,35 @@ module Rigor
|
|
|
185
246
|
end
|
|
186
247
|
end
|
|
187
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
|
+
|
|
188
278
|
def resolve_class_name(class_name_or_object)
|
|
189
279
|
name =
|
|
190
280
|
case class_name_or_object
|
|
@@ -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
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Type
|
|
7
|
+
# `Refined[base, predicate_id]` — predicate-subset half of
|
|
8
|
+
# the OQ3 refinement-carrier strategy
|
|
9
|
+
# ([ADR-3](docs/adr/3-type-representation.md), Working
|
|
10
|
+
# Decision Option C). Sibling of `Type::Difference`, which
|
|
11
|
+
# carries the point-removal half.
|
|
12
|
+
#
|
|
13
|
+
# lowercase-string = Refined[Nominal[String], :lowercase]
|
|
14
|
+
# uppercase-string = Refined[Nominal[String], :uppercase]
|
|
15
|
+
# numeric-string = Refined[Nominal[String], :numeric]
|
|
16
|
+
#
|
|
17
|
+
# The carrier wraps a base type and a `predicate_id` Symbol
|
|
18
|
+
# drawn from {PREDICATES}. The recogniser is invoked at
|
|
19
|
+
# constant-fold and acceptance time over a `Constant<base>`
|
|
20
|
+
# value; for non-Constant receivers the carrier is a marker
|
|
21
|
+
# the catalog tier consults to project `String#downcase` /
|
|
22
|
+
# `String#upcase` (etc.) into the matching refinement.
|
|
23
|
+
#
|
|
24
|
+
# Display routes through {CANONICAL_NAMES}: registered
|
|
25
|
+
# `(base_class_name, predicate_id)` pairs print in their
|
|
26
|
+
# kebab-case spelling (`lowercase-string`); unregistered
|
|
27
|
+
# combinations fall back to the `base & predicate?` operator
|
|
28
|
+
# form per
|
|
29
|
+
# [`type-operators.md`](docs/type-specification/type-operators.md).
|
|
30
|
+
#
|
|
31
|
+
# Construction MUST go through `Type::Combinator.refined` /
|
|
32
|
+
# the per-name factories (`Combinator.lowercase_string`,
|
|
33
|
+
# `Combinator.uppercase_string`, `Combinator.numeric_string`).
|
|
34
|
+
# Direct `.new` is an internal escape hatch for tests and
|
|
35
|
+
# combinator's own implementation.
|
|
36
|
+
class Refined
|
|
37
|
+
attr_reader :base, :predicate_id
|
|
38
|
+
|
|
39
|
+
def initialize(base, predicate_id)
|
|
40
|
+
raise ArgumentError, "predicate_id must be a Symbol" unless predicate_id.is_a?(Symbol)
|
|
41
|
+
|
|
42
|
+
@base = base
|
|
43
|
+
@predicate_id = predicate_id
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def describe(verbosity = :short)
|
|
48
|
+
named = canonical_name
|
|
49
|
+
return named if named
|
|
50
|
+
|
|
51
|
+
"#{base.describe(verbosity)} & #{predicate_id}?"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Erases to the base nominal: every refinement MUST erase
|
|
55
|
+
# to its base per [`rbs-erasure.md`](docs/type-specification/rbs-erasure.md).
|
|
56
|
+
def erase_to_rbs
|
|
57
|
+
base.erase_to_rbs
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def top
|
|
61
|
+
Trinary.no
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def bot
|
|
65
|
+
Trinary.no
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def dynamic
|
|
69
|
+
base.respond_to?(:dynamic) ? base.dynamic : Trinary.no
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def accepts(other, mode: :gradual)
|
|
73
|
+
Inference::Acceptance.accepts(self, other, mode: mode)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ==(other)
|
|
77
|
+
other.is_a?(Refined) && base == other.base && predicate_id == other.predicate_id
|
|
78
|
+
end
|
|
79
|
+
alias eql? ==
|
|
80
|
+
|
|
81
|
+
def hash
|
|
82
|
+
[Refined, base, predicate_id].hash
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inspect
|
|
86
|
+
"#<Rigor::Type::Refined #{describe(:short)}>"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Recognises a Ruby value against this carrier's
|
|
90
|
+
# predicate. The trinary return is intentional: `true` /
|
|
91
|
+
# `false` when the predicate registry decides, `nil`
|
|
92
|
+
# when the predicate is unknown to the registry, so
|
|
93
|
+
# callers (today {Inference::Acceptance}) can fall
|
|
94
|
+
# through to gradual-mode `:maybe`.
|
|
95
|
+
# rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
|
96
|
+
def matches?(value)
|
|
97
|
+
recogniser = PREDICATES[predicate_id]
|
|
98
|
+
return nil if recogniser.nil?
|
|
99
|
+
|
|
100
|
+
!!recogniser.call(value)
|
|
101
|
+
end
|
|
102
|
+
# rubocop:enable Style/ReturnNilInPredicateMethodDefinition
|
|
103
|
+
|
|
104
|
+
# `predicate_id => recogniser` table. The recogniser is
|
|
105
|
+
# called with a Ruby value (typically the inner `value`
|
|
106
|
+
# of a `Constant`) and returns truthy when the value
|
|
107
|
+
# satisfies the predicate. The recogniser MUST be total
|
|
108
|
+
# (return false rather than raise) over arbitrary input,
|
|
109
|
+
# so callers can pass any `Constant#value` without a
|
|
110
|
+
# type-prefilter.
|
|
111
|
+
#
|
|
112
|
+
# Plugin-contributed predicates land here once ADR-2 is
|
|
113
|
+
# in flight; today the table is closed over the v0.0.4
|
|
114
|
+
# built-in catalogue.
|
|
115
|
+
#
|
|
116
|
+
# Recogniser policy:
|
|
117
|
+
#
|
|
118
|
+
# - `:numeric` is deliberately conservative — only decimal
|
|
119
|
+
# integer and plain-decimal-fraction strings are
|
|
120
|
+
# recognised, mirroring `imported-built-in-types.md`'s
|
|
121
|
+
# "Rigor's numeric-string predicate" wording. Looser
|
|
122
|
+
# forms (scientific, hex, rational) MAY join later
|
|
123
|
+
# without breaking the registry contract.
|
|
124
|
+
# - `:decimal_int` is "what `Integer(s, 10)` would parse
|
|
125
|
+
# without remainder" — one or more decimal digits,
|
|
126
|
+
# optional leading sign, no whitespace, no fractional
|
|
127
|
+
# tail.
|
|
128
|
+
# - `:octal_int` and `:hex_int` REQUIRE their conventional
|
|
129
|
+
# prefix (`0o` / `0O` / leading `0` for octal; `0x` /
|
|
130
|
+
# `0X` for hex) so the predicate is disjoint from
|
|
131
|
+
# `:decimal_int`. A bare `"755"` is decimal-int-string,
|
|
132
|
+
# not octal-int-string. This matches the typical user
|
|
133
|
+
# intent — a refinement marks a string that "looks like
|
|
134
|
+
# octal", not "happens to be base-8 valid".
|
|
135
|
+
NUMERIC_STRING_PATTERN = /\A-?\d+(?:\.\d+)?\z/
|
|
136
|
+
DECIMAL_INT_STRING_PATTERN = /\A-?\d+\z/
|
|
137
|
+
OCTAL_INT_STRING_PATTERN = /\A-?(?:0[oO][0-7]+|0[0-7]+)\z/
|
|
138
|
+
HEX_INT_STRING_PATTERN = /\A-?0[xX][0-9a-fA-F]+\z/
|
|
139
|
+
private_constant :NUMERIC_STRING_PATTERN, :DECIMAL_INT_STRING_PATTERN,
|
|
140
|
+
:OCTAL_INT_STRING_PATTERN, :HEX_INT_STRING_PATTERN
|
|
141
|
+
|
|
142
|
+
PREDICATES = {
|
|
143
|
+
lowercase: ->(v) { v.is_a?(String) && v == v.downcase },
|
|
144
|
+
uppercase: ->(v) { v.is_a?(String) && v == v.upcase },
|
|
145
|
+
numeric: ->(v) { v.is_a?(String) && NUMERIC_STRING_PATTERN.match?(v) },
|
|
146
|
+
decimal_int: ->(v) { v.is_a?(String) && DECIMAL_INT_STRING_PATTERN.match?(v) },
|
|
147
|
+
octal_int: ->(v) { v.is_a?(String) && OCTAL_INT_STRING_PATTERN.match?(v) },
|
|
148
|
+
hex_int: ->(v) { v.is_a?(String) && HEX_INT_STRING_PATTERN.match?(v) }
|
|
149
|
+
}.freeze
|
|
150
|
+
|
|
151
|
+
# Maps `[base_class_name, predicate_id]` pairs to their
|
|
152
|
+
# kebab-case canonical name. Registered shapes print
|
|
153
|
+
# through `describe`; unregistered combinations fall back
|
|
154
|
+
# to the operator form.
|
|
155
|
+
CANONICAL_NAMES = {
|
|
156
|
+
["String", :lowercase] => "lowercase-string",
|
|
157
|
+
["String", :uppercase] => "uppercase-string",
|
|
158
|
+
["String", :numeric] => "numeric-string",
|
|
159
|
+
["String", :decimal_int] => "decimal-int-string",
|
|
160
|
+
["String", :octal_int] => "octal-int-string",
|
|
161
|
+
["String", :hex_int] => "hex-int-string"
|
|
162
|
+
}.freeze
|
|
163
|
+
private_constant :CANONICAL_NAMES
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def canonical_name
|
|
168
|
+
return nil unless base.is_a?(Nominal)
|
|
169
|
+
|
|
170
|
+
CANONICAL_NAMES[[base.class_name, predicate_id]]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/rigor/type.rb
CHANGED
|
@@ -21,5 +21,7 @@ require_relative "type/tuple"
|
|
|
21
21
|
require_relative "type/hash_shape"
|
|
22
22
|
require_relative "type/union"
|
|
23
23
|
require_relative "type/difference"
|
|
24
|
+
require_relative "type/refined"
|
|
25
|
+
require_relative "type/intersection"
|
|
24
26
|
require_relative "type/accepts_result"
|
|
25
27
|
require_relative "type/combinator"
|