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.
@@ -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
- PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative) do
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
- (?<negation>~?)
110
- (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
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 == "self" ? :self : :parameter
125
- target_name = target == "self" ? :self : target.to_sym
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: match[:negation].to_s == "~"
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
- (?<negation>~?)
161
- (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
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
- class_name = match[:class_name].to_s.sub(/\A::/, "")
184
- target_kind = target == "self" ? :self : :parameter
185
- target_name = target == "self" ? :self : target.to_sym
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: match[:negation].to_s == "~"
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
- (?<refinement>[a-z][a-z0-9-]*)
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.lookup(match[:refinement])
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"