plumb 0.0.18 → 0.2.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +834 -63
  3. data/bench/compare_dry_schema.rb +79 -0
  4. data/bench/compare_dry_types.rb +37 -0
  5. data/bench/compare_parametric_schema.rb +2 -80
  6. data/bench/dry_schema_hash.rb +103 -0
  7. data/bench/dry_types_hash.rb +125 -0
  8. data/bench/json_schema_profile.rb +107 -0
  9. data/bench/plumb_hash.rb +17 -11
  10. data/bench/results_allocations.rb +137 -0
  11. data/bench/sample_data.rb +78 -0
  12. data/examples/command_objects.rb +1 -1
  13. data/examples/concurrent_downloads.rb +16 -9
  14. data/examples/event_registry.rb +6 -1
  15. data/examples/weekdays.rb +1 -1
  16. data/lib/plumb/and.rb +63 -6
  17. data/lib/plumb/any_class.rb +12 -2
  18. data/lib/plumb/array_class.rb +64 -19
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +59 -19
  21. data/lib/plumb/codec.rb +795 -0
  22. data/lib/plumb/composable.rb +451 -39
  23. data/lib/plumb/conjunction.rb +50 -0
  24. data/lib/plumb/constraint.rb +234 -0
  25. data/lib/plumb/covariant_fusion.rb +46 -0
  26. data/lib/plumb/decorator.rb +12 -22
  27. data/lib/plumb/deferred.rb +13 -5
  28. data/lib/plumb/disjunction.rb +112 -0
  29. data/lib/plumb/encoder.rb +207 -0
  30. data/lib/plumb/function.rb +347 -0
  31. data/lib/plumb/hash_class.rb +338 -32
  32. data/lib/plumb/hash_map.rb +62 -14
  33. data/lib/plumb/implementation.rb +247 -0
  34. data/lib/plumb/interface_class.rb +21 -2
  35. data/lib/plumb/intersection.rb +47 -0
  36. data/lib/plumb/json_schema_visitor.rb +255 -36
  37. data/lib/plumb/key.rb +63 -13
  38. data/lib/plumb/mermaid_visitor.rb +129 -0
  39. data/lib/plumb/metadata.rb +10 -1
  40. data/lib/plumb/metadata_visitor.rb +36 -34
  41. data/lib/plumb/never_class.rb +38 -0
  42. data/lib/plumb/node_mapper.rb +97 -0
  43. data/lib/plumb/not.rb +34 -2
  44. data/lib/plumb/optimizer.rb +444 -0
  45. data/lib/plumb/or.rb +26 -29
  46. data/lib/plumb/pipeline.rb +99 -11
  47. data/lib/plumb/policy.rb +17 -4
  48. data/lib/plumb/range_class.rb +46 -0
  49. data/lib/plumb/relation.rb +57 -0
  50. data/lib/plumb/result.rb +55 -23
  51. data/lib/plumb/semantic_matcher.rb +393 -0
  52. data/lib/plumb/static_class.rb +20 -1
  53. data/lib/plumb/stream_class.rb +32 -6
  54. data/lib/plumb/subtyping.rb +461 -0
  55. data/lib/plumb/tagged_hash.rb +45 -4
  56. data/lib/plumb/tuple_class.rb +21 -4
  57. data/lib/plumb/type_cache.rb +41 -0
  58. data/lib/plumb/type_registry.rb +71 -0
  59. data/lib/plumb/typed_step.rb +67 -0
  60. data/lib/plumb/types.rb +27 -43
  61. data/lib/plumb/union.rb +30 -0
  62. data/lib/plumb/value_class.rb +20 -1
  63. data/lib/plumb/version.rb +1 -1
  64. data/lib/plumb/visitor_handlers.rb +20 -4
  65. data/lib/plumb.rb +90 -3
  66. metadata +30 -8
  67. data/lib/plumb/build.rb +0 -22
  68. data/lib/plumb/match_class.rb +0 -42
  69. data/lib/plumb/schema.rb +0 -195
  70. data/lib/plumb/step.rb +0 -27
  71. data/lib/plumb/transform.rb +0 -26
@@ -0,0 +1,795 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/composable'
4
+ require 'plumb/encoder'
5
+
6
+ module Plumb
7
+ # A group of {Encoder}s that applies itself to whole types at composition
8
+ # time. A Codec knows nothing about any particular format — only its
9
+ # encoders, plus a list of pass-through types (`.noop`) already valid in
10
+ # the target format.
11
+ #
12
+ # class JSONCodec < Plumb::Codec
13
+ # noop Types::String, Types::Integer, Types::Float, Types::Numeric,
14
+ # Types::True, Types::False, Types::Nil, Types::Hash, Types::Array
15
+ #
16
+ # encoder JSONDateRangeEncoder # Hash[from: Date, to: Date] <=> DateRange
17
+ # encoder ISODateEncoder # String <=> Date
18
+ # end
19
+ #
20
+ # JSONPerson = JSONCodec >> Person # decode: input structures -> Person
21
+ # EncodedPerson = Person >> JSONCodec # encode: Person -> output structures
22
+ #
23
+ # Composing rewrites the type deeply: every field (at any depth) whose type
24
+ # matches an encoder's output type is replaced with the oriented
25
+ # encoder step (a plain Function, see Encoder.step); noop-matched types
26
+ # pass through unchanged; anything else
27
+ # raises Plumb::TypeError at composition time, naming the field path. An
28
+ # encoder's input type is itself rewritten through the same codec, so
29
+ # nested non-native values resolve via other encoders in the group.
30
+ #
31
+ # The Codec leaves no runtime node behind — the result is ordinary Plumb
32
+ # algebra, so parsing, subtyping and visitors work on it unchanged. It works
33
+ # on any type, not just Hash schemas: `JSONCodec >> Types::Date` returns the
34
+ # matched encoder's decode step.
35
+ class Codec
36
+ include Composable
37
+
38
+ class << self
39
+ # Register one or more Encoder subclasses, in matching-priority order.
40
+ # The registry is inheritable: subclassing a codec extends it, and a
41
+ # subclass's encoders take precedence over inherited ones when their
42
+ # types are equivalent.
43
+ def encoder(*encoder_classes)
44
+ encoder_classes.each do |enc|
45
+ unless enc.is_a?(::Class) && enc < Encoder
46
+ raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
47
+ end
48
+
49
+ own_encoders << enc
50
+ end
51
+ self
52
+ end
53
+
54
+ # Register pass-through types: values of these types are already valid
55
+ # in the codec's target format and are left untouched — they rewrite to
56
+ # nothing. Real encoders always match first, so a noop never shadows a
57
+ # registered encoder for the same type.
58
+ def noop(*types)
59
+ types.each { |t| own_noop_types << Composable.wrap(t) }
60
+ self
61
+ end
62
+
63
+ # All registered encoders, inherited first, own last (later registrations
64
+ # win ties in matching).
65
+ def encoders = inherited_registry(:encoders, own_encoders)
66
+
67
+ # All registered pass-through types, inherited first.
68
+ def noop_types = inherited_registry(:noop_types, own_noop_types)
69
+
70
+ # Class-level composition delegates to a memoized instance, so a Codec
71
+ # subclass composes directly: `JSONCodec >> Person`.
72
+ def instance = @instance ||= new
73
+ def >>(other) = instance >> other
74
+ def |(other) = instance | other
75
+ def &(other) = instance & other
76
+ def for(type) = instance.for(type)
77
+ def to_plumb_type(op:, left:) = instance.to_plumb_type(op:, left:)
78
+ def to_composable = instance
79
+ def call(result) = instance.call(result)
80
+
81
+ private
82
+
83
+ # A registry a subclass extends rather than replaces: whatever the
84
+ # ancestor exposes under `name`, plus this class's own registrations.
85
+ def inherited_registry(name, own)
86
+ inherited = superclass.respond_to?(name) ? superclass.public_send(name) : BLANK_ARRAY
87
+ inherited + own
88
+ end
89
+
90
+ def own_encoders = @own_encoders ||= []
91
+ def own_noop_types = @own_noop_types ||= []
92
+ end
93
+
94
+ attr_reader :encoders, :noop_types
95
+
96
+ # @param extra_encoders [Array<Class>] encoders for this instance, appended
97
+ # after (and winning ties over) the class-level registry.
98
+ def initialize(*extra_encoders)
99
+ @encoders = self.class.encoders + extra_encoders
100
+ @noop_types = self.class.noop_types
101
+ @noop_union = @noop_types.reduce(:|)
102
+ freeze
103
+ end
104
+
105
+ # Decode direction: rewrite `other` so it accepts the encoders' input form
106
+ # and produces the output values `other` describes. The rewritten type
107
+ # REPLACES the composition — no codec node remains.
108
+ def >>(other)
109
+ Rewriter.new(self, :decode).call(Composable.wrap(other))
110
+ end
111
+
112
+ # Both directions for a type, as a [decoding, encoding] pair:
113
+ #
114
+ # decoder, encoder = Plumb::Codec::JSON.for(Person)
115
+ # decoder.parse(input_data) # => a Person hash
116
+ # encoder.parse(person) # => output structures
117
+ #
118
+ # @param type [Composable, Object]
119
+ # @return [Array(Composable, Composable)]
120
+ def for(type)
121
+ type = Composable.wrap(type)
122
+ [self >> type, type >> self]
123
+ end
124
+
125
+ # Encode direction, reached when the codec is the RIGHT operand
126
+ # (`Person >> JSONCodec` — see Composable#to_plumb_type): build an encode
127
+ # rewrite of what `left` produces. Composable#>> then composes
128
+ # `And(left, rewrite)`: the left validates its input once, the
129
+ # rewrite encodes. Building from `left`'s OUTPUT (not `left` itself)
130
+ # avoids re-running its coercions on already-parsed values.
131
+ def to_plumb_type(op:, left:)
132
+ unless op == :>>
133
+ raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
134
+ end
135
+
136
+ left = Composable.wrap(left)
137
+ # A plain-include struct wraps as an opaque Step (output Any), so its
138
+ # rewrite target is the struct node itself, not its resolved output.
139
+ target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
140
+ Rewriter.new(self, :encode).call(target)
141
+ end
142
+
143
+ def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"
144
+ alias & |
145
+
146
+ def call(_result)
147
+ raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
148
+ end
149
+
150
+ # The best matching encoder for `type`, or nil. Matching is against each
151
+ # encoder's output type — schemas are written in output
152
+ # terms in both directions. Most-specific wins; equivalent types
153
+ # tie-break to the last registered; incomparable multi-matches raise.
154
+ def encoder_for(type, path = BLANK_ARRAY)
155
+ matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
156
+ return nil if matches.empty?
157
+ return matches.first if matches.size == 1
158
+
159
+ minimal = matches.reject do |e|
160
+ # e is dominated when another match's output type is strictly narrower.
161
+ matches.any? do |o|
162
+ !o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
163
+ end
164
+ end
165
+
166
+ first = minimal.first
167
+ unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
168
+ raise Plumb::TypeError,
169
+ "#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
170
+ "#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
171
+ end
172
+
173
+ minimal.last
174
+ end
175
+
176
+ # Is `type` (one side of it, per direction) covered by the registered noop
177
+ # types? Decode checks what the type ACCEPTS (it will be fed raw input
178
+ # data); encode checks what it PRODUCES (its output lands in the encoded
179
+ # document).
180
+ def noop?(type, direction)
181
+ return false unless @noop_union
182
+
183
+ side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
184
+ # A pure filter with an opaque side is judged by the type itself: a
185
+ # bare-matcher Constraint (a branch of a factored refinement union, eg.
186
+ # the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
187
+ # as its accepted type, but as a value-preserving refinement it IS its
188
+ # own honest description.
189
+ side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
190
+ Plumb::Subtyping.subtype?(side, @noop_union)
191
+ end
192
+
193
+ # Is this concrete VALUE covered by the noop types? Used for Static nodes,
194
+ # whose fixed value can be validated directly — subtyping over the node
195
+ # can't relate an atomic Static to a container noop (`Static[[]]` vs
196
+ # `Types::Array`), but the value itself can just be checked.
197
+ def noop_value?(value)
198
+ return false unless @noop_union
199
+
200
+ @noop_union === value
201
+ end
202
+
203
+ def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"
204
+
205
+ private def _inspect
206
+ "#{self.class == Codec ? 'Plumb::Codec' : self.class.name}[#{encoders.map(&:inspect).join(', ')}]"
207
+ end
208
+
209
+ # The deep rewrite walker. Top-down, per node:
210
+ #
211
+ # 1. Static values, Or/And chains and transparent wrappers recurse into
212
+ # their parts first — they can carry generator machinery (a
213
+ # `.default`'s `Undefined >> Static` guard) that a wholesale
214
+ # replacement would drop (see #visit);
215
+ # 2. a real encoder match replaces the node whole (its input type is
216
+ # recursively rewritten through the same codec, cycle-guarded);
217
+ # 3. structured composites (Hash schemas, typed Arrays/Tuples/HashMaps)
218
+ # recurse into their children — AFTER encoder matching, so an encoder
219
+ # can target a specific composite shape, but BEFORE the noop check,
220
+ # so a generic `noop Types::Hash` can't swallow a structured schema;
221
+ # 4. decoding, a converting leaf (a Function, a Plumb::Implementation, a
222
+ # struct) has what it ACCEPTS rewritten through the codec and put in
223
+ # front of it, so the step is fed the decoded value (see #bridge_input);
224
+ # 5. remaining leaves pass through when noop-covered, otherwise raise with
225
+ # the dotted field path.
226
+ #
227
+ # Untouched subtrees keep their identity (the original node is returned),
228
+ # so noop pass-through adds no nodes.
229
+ class Rewriter
230
+ def initialize(codec, direction)
231
+ @codec = codec
232
+ @direction = direction # :decode | :encode
233
+ @deferred_memo = {}.compare_by_identity
234
+ @input_memo = {}.compare_by_identity
235
+ @match_memo = {}.compare_by_identity
236
+ @noop_memo = {}.compare_by_identity
237
+ @bridge_memo = {}.compare_by_identity
238
+ @input_stack = []
239
+ @root = nil
240
+ end
241
+
242
+ def call(type)
243
+ @root = type
244
+ visit(type, BLANK_ARRAY)
245
+ end
246
+
247
+ private
248
+
249
+ # Encoder matching and the noop check are pure functions of the (frozen)
250
+ # type node, the codec and the direction — but each costs a subtype? walk
251
+ # per registered encoder, and the same type object recurs across the
252
+ # fields of a schema (a shared `Types::Date` constant, a repeated
253
+ # `Types::String`). Memoize both per rewrite. As with @input_memo, `path`
254
+ # only feeds error text, so the first visit's path is the one reported.
255
+ def encoder_for(type, path)
256
+ @match_memo.fetch(type) { @match_memo[type] = @codec.encoder_for(type, path) }
257
+ end
258
+
259
+ def noop?(type)
260
+ @noop_memo.fetch(type) { @noop_memo[type] = @codec.noop?(type, @direction) }
261
+ end
262
+
263
+ def visit(type, path)
264
+ # These nodes are handled BEFORE encoder matching, because a wholesale
265
+ # replacement would drop machinery that is subtype-wise invisible:
266
+ #
267
+ # - a Static's fixed value matches an encoder atomically
268
+ # (`Static[a_date]` is a subtype of Date), and replacing it with a
269
+ # step expecting encoded input loses the static behaviour (eg. the
270
+ # default value in a `.default(...)`);
271
+ # - Or/And chains and transparent wrappers can carry generator guards
272
+ # (`(Undefined >> Static) | T`, the #default shape) — the whole
273
+ # composition IS a subtype of T, so an encoder would match and
274
+ # swallow the guard. Recursing rewrites the data-bearing parts and
275
+ # keeps the machinery; an encoder with a union output type still
276
+ # matches at branch level.
277
+ #
278
+ # Untouched subtrees come back identical, so there is no shortcut to
279
+ # take here — a whole-composite noop check would let a container-top
280
+ # noop (`noop Types::Array`) swallow a rewritable union
281
+ # (`Array[Date] | String`).
282
+ case type
283
+ when Deferred then return visit_deferred(type, path)
284
+ when StaticClass then return visit_static(type, path)
285
+ when Disjunction then return visit_or(type, path)
286
+ # Intersection BEFORE Conjunction — it is one too, and the two need
287
+ # opposite treatment (a meet may be reordered, a pipeline may not).
288
+ when Intersection then return visit_intersection(type, path)
289
+ when Conjunction then return visit_composition(type, path)
290
+ # Transparent wrappers: NodeMapper owns how each is rebuilt, so there is no
291
+ # second table of recipes to keep in step with it.
292
+ when Metadata, Policy, Composable::Node
293
+ return NodeMapper.map(type) { |t| visit(t, path) }
294
+ end
295
+
296
+ enc = encoder_for(type, path)
297
+ return replace(type, enc, path) if enc
298
+
299
+ case type
300
+ when HashClass then visit_hash(type, path)
301
+ when ArrayClass, StreamClass then visit_array(type, path)
302
+ when TupleClass then visit_tuple(type, path)
303
+ when HashMap then visit_hash_map(type, path)
304
+ when TaggedHash then visit_tagged_hash(type, path)
305
+ else
306
+ if (struct = Plumb::Attributes.struct_class(type))
307
+ visit_struct(type, struct, path)
308
+ else
309
+ visit_leaf(type, path)
310
+ end
311
+ end
312
+ end
313
+
314
+ # A leaf: nothing to recurse into structurally. It survives when the
315
+ # format already carries it (the noop check), or — decoding — when the
316
+ # codec can bridge what it CONSUMES.
317
+ def visit_leaf(type, path)
318
+ bridge_input(type, path) || noop_or_fail(type, path)
319
+ end
320
+
321
+ # DECODE: a converting leaf (a Function, a Plumb::Implementation — any node
322
+ # whose accepted type is a distinct node) consumes values the encoded
323
+ # document does not carry: `Hash[time: Time] -> Thing` wants a Time, a JSON
324
+ # document has a String. Rewrite what it ACCEPTS through this codec and put
325
+ # that in front, so the step is fed the decoded value and is itself
326
+ # preserved. This is exactly the rule #visit_struct applies to a struct's
327
+ # schema — a struct IS such a node (`Hash[...] -> Person`) — generalized to
328
+ # every converting node. (Structs keep their own branch: encoding, they
329
+ # also need the `#attributes` extraction.)
330
+ #
331
+ # Returns nil when it does not apply: a leaf that accepts itself (any plain
332
+ # matcher), an opaque one, or one whose accepted side is already native —
333
+ # there the rewrite comes back identical and the noop check decides.
334
+ #
335
+ # ORDERING: this must run BEFORE the noop check, not after. A step
336
+ # accepting a typed Hash is "covered" by a generic `noop Types::Hash` — the
337
+ # same container-top shadowing #visit's ordering avoids for a HashClass
338
+ # node — so deferring to the noop check passes it through unrewritten and
339
+ # yields a decoder that type-errors at runtime.
340
+ def bridge_input(type, path)
341
+ return nil unless @direction == :decode
342
+
343
+ accepted = Plumb::Subtyping.accepted_type(type)
344
+ return nil if accepted.equal?(type) || accepted.is_a?(AnyClass)
345
+
346
+ rewritten = @bridge_memo.fetch(type) do
347
+ @bridge_memo[type] = visit(accepted, path + ["<#{type.inspect} input>"])
348
+ end
349
+ return nil if rewritten.equal?(accepted)
350
+
351
+ # The same node shape #visit_struct builds: ONE Function, whose input is
352
+ # the decoded form and whose output type IS the step (a Function's output
353
+ # stage calls it). So the JSON Schema describes the encoded side only —
354
+ # an `And` would merge the step's own input type back in and leak the
355
+ # decoded type into the wire description.
356
+ Function.new(rewritten, type, Plumb::NOOP)
357
+ end
358
+
359
+ # A struct (Types::Data / Plumb::Attributes) is a Hash schema plus a
360
+ # constructor. Decoding, the rewritten schema turns input fields into
361
+ # output values and the class itself builds the instance (Function's
362
+ # output stage CALLS it). Encoding, the class validates/constructs the
363
+ # instance, `#attributes` exposes the output values (shallow — nested
364
+ # structs stay instances and are handled by their own rewritten nodes,
365
+ # unlike the deep #to_h), and the encode-rewritten schema turns them
366
+ # into input values. Either way a single Function node, so accepted/
367
+ # produced types and the JSON Schema stay honest.
368
+ def visit_struct(original, struct, path)
369
+ schema = visit(struct._schema, path)
370
+ if @direction == :decode
371
+ # All fields input-native already: the struct validates and
372
+ # constructs by itself.
373
+ return original if schema.equal?(struct._schema)
374
+
375
+ Function.new(schema, struct, Plumb::NOOP)
376
+ else
377
+ # The lambda is fresh per call, so name what the step IS as its identity —
378
+ # otherwise `(Person >> Codec) == (Person >> Codec)` is false while the
379
+ # decode direction (which passes NOOP) is true. @see Function#==
380
+ Function.new(struct, schema, ->(result) { result.valid!(result.value.attributes) },
381
+ identity: [:struct_encode, struct])
382
+ end
383
+ end
384
+
385
+ # A Static ignores its input and emits a fixed value (eg. the default in
386
+ # a `.default(...)` composition). Decoding, that value is part of the
387
+ # output structure the schema declares — keep it as-is.
388
+ #
389
+ # Encoding is different: the suffix runs on values the schema has
390
+ # ALREADY produced (the Static ran when the schema did), and
391
+ # resolved_output collapses the `Undefined >> Static` guard of a
392
+ # #default, leaving the Static as an always-matching first Or branch —
393
+ # re-running it would clobber the actual value with the default. So on
394
+ # encode a Static becomes a CHECKED value: match the static value
395
+ # (against the VALUE, not the node — subtyping can't relate an atomic
396
+ # Static to a container noop), then emit its input form — the value
397
+ # itself when noop-covered, or its encoding, computed once here at
398
+ # composition time (the value is fixed, so its encoding is too).
399
+ def visit_static(type, path)
400
+ return type if @direction == :decode
401
+
402
+ value = type.children.first
403
+ # Encoder match FIRST, then noop — mirrors the invariant that real
404
+ # encoders always take precedence over pass-through types. Otherwise a
405
+ # static default whose value happens to satisfy a broad noop (eg. a
406
+ # BigDecimal under the Numeric noop) would emit raw instead of encoding.
407
+ if (enc = encoder_for(type, path))
408
+ encoded = replace(type, enc, path).parse(value)
409
+ Conjunction.build(ValueClass.new(value), StaticClass.new(encoded.freeze))
410
+ elsif @codec.noop_value?(value)
411
+ ValueClass.new(value)
412
+ else
413
+ noop_or_fail(type, path)
414
+ end
415
+ end
416
+
417
+ # Replace a matched type with the oriented Step. The encoder's input
418
+ # type is itself rewritten through this codec — that's what lets
419
+ # `Hash[from: Date, to: Date]` resolve its Dates via a String<=>Date
420
+ # encoder in the same group. The rewritten input type (and, when the
421
+ # matched type is a value-preserving strict subtype of the encoder's
422
+ # output, the narrowed type) are spliced into the Step, so each rewritten
423
+ # field stays a single node.
424
+ def replace(type, enc, path)
425
+ # An encoder whose own input type contains its output type re-matches
426
+ # while we rewrite that input (enc is on the stack). If that output is
427
+ # noop-covered it's a literal acceptance, not a recursive encoding —
428
+ # leave the type alone (eg. Forms' NilEncoder input `String[''] | Nil`,
429
+ # so a nullable field decodes a literal nil). Otherwise it's a genuine
430
+ # input-type cycle.
431
+ if @input_stack.include?(enc)
432
+ return type if noop?(type)
433
+
434
+ raise Plumb::TypeError,
435
+ "#{@codec.inspect}: encoder input-type cycle: #{(@input_stack + [enc]).map(&:inspect).join(' -> ')}"
436
+ end
437
+
438
+ # A generic encoder builds its input from the matched type (see
439
+ # Encoder.input_type_for), so the input — and its rewrite — vary per
440
+ # matched type, NOT per encoder: memoize by matched type (direction is
441
+ # fixed per Rewriter; the path only feeds error text on the first
442
+ # visit). Fields sharing a type object (eg. the same `Types::Date`
443
+ # constant) still rewrite their input once.
444
+ input = enc.input_type_for(type)
445
+ rewritten_input = @input_memo.fetch(type) do
446
+ begin
447
+ @input_stack.push(enc)
448
+ @input_memo[type] = visit(input, path + ["<#{enc.inspect} input>"])
449
+ ensure
450
+ @input_stack.pop
451
+ end
452
+ end
453
+
454
+ # Splice the input into the step unless it is the encoder's declared
455
+ # input type unchanged (then the base step already carries it). A
456
+ # generic encoder's member-specialized input is never that base type, so
457
+ # it is always spliced — the step reports the specific input, not the top.
458
+ input_arg = rewritten_input.equal?(enc.input_type) ? nil : rewritten_input
459
+ enc.step(@direction, input_side: input_arg, output_side: narrowed_side(type, enc))
460
+ end
461
+
462
+ # When the matched type is strictly narrower than the encoder's declared
463
+ # output AND is a pure filter, keep it as the output-side check (decode
464
+ # re-validates the produced value against it; encode validates the input
465
+ # against it). A value-CONVERTING narrower type is dropped — re-running
466
+ # its conversion on an already-decoded value would be wrong; the
467
+ # encoder's declared type stands in.
468
+ def narrowed_side(type, enc)
469
+ return nil unless Plumb::Subtyping.value_preserving?(type)
470
+ return nil unless Plumb::Subtyping.strict_subtype?(type, enc.output_type)
471
+
472
+ type
473
+ end
474
+
475
+ def visit_hash(type, path)
476
+ return noop_or_fail(type, path) if type._schema.empty? # the bare "any Hash" — a leaf
477
+
478
+ NodeMapper.map_record(type) do |field, key|
479
+ visit(field, path + [key.literal? ? key.to_s : key.inspect])
480
+ end
481
+ end
482
+
483
+ def visit_array(type, path)
484
+ return noop_or_fail(type, path) if type.children.first.is_a?(AnyClass) # untyped — a leaf
485
+
486
+ NodeMapper.map_children(type) { |element| visit(element, path + ['[]']) }
487
+ end
488
+
489
+ def visit_tuple(type, path)
490
+ members = type.children.each_with_index.map { |m, i| visit(m, path + ["[#{i}]"]) }
491
+ members.zip(type.children).all? { |v, m| v.equal?(m) } ? type : type.of(*members)
492
+ end
493
+
494
+ # Keys are left untouched — key normalization (eg. string keys from the
495
+ # input) is a separate concern (see Types::SymbolizedHash / #symbolized).
496
+ def visit_hash_map(type, path)
497
+ key_type, value_type = type.children
498
+ v = visit(value_type, path + ['{}'])
499
+ # type.class (not HashMap) to preserve FilteredHashMap's leniency.
500
+ v.equal?(value_type) ? type : type.class.new(key_type, v)
501
+ end
502
+
503
+ # A tagged union of Hash variants discriminated by a key: rewrite each
504
+ # variant schema (all HashClasses); the tag key's literal value is a
505
+ # native pass-through, so the discriminator survives.
506
+ def visit_tagged_hash(type, path)
507
+ NodeMapper.map_children(type) { |variant| visit(variant, path) }
508
+ end
509
+
510
+ def visit_or(type, path)
511
+ left, right = type.children
512
+ l = visit(left, path)
513
+ r = visit(right, path)
514
+ l.equal?(left) && r.equal?(right) ? type : Disjunction.build(l, r)
515
+ end
516
+
517
+ # A MEET: a data-bearing type refined by pure filters (a #where's
518
+ # AttributeValueMatch, a #check Constraint, ...). Every side constrains the SAME
519
+ # value and nothing converts, so the sides may be flattened and reordered freely,
520
+ # which is what makes the partition sound.
521
+ #
522
+ # On DECODE the codec replaces the schema, so the refinements are the only
523
+ # validation of the decoded value — keep them AFTER the conversion. On ENCODE the
524
+ # value was already validated upstream and they would run on the encoded form, so
525
+ # drop them.
526
+ def visit_intersection(type, path)
527
+ steps = flatten_intersection(type)
528
+ refinements, data = steps.partition { |s| pure_refinement?(s) }
529
+ rewritten = data.map { |d| visit(d, path) }
530
+ return type if rewritten.each_with_index.all? { |r, i| r.equal?(data[i]) }
531
+
532
+ ordered = @direction == :decode ? rewritten + refinements : rewritten
533
+ ordered.reduce { |l, r| Conjunction.build(l, r) }
534
+ end
535
+
536
+ # Flattens MEETS ONLY. Descending through a composition here is what caused
537
+ # a refinement to be lifted past a conversion — see #visit_composition.
538
+ def flatten_intersection(type)
539
+ type.children.flat_map { |c| c.is_a?(Intersection) ? flatten_intersection(c) : [c] }
540
+ end
541
+
542
+ # A PIPELINE (`And`): each step consumes what the previous one produced, so
543
+ # position is meaning. Nothing may be flattened out of it, reordered, or
544
+ # lifted — visit both sides IN PLACE.
545
+ #
546
+ # The meet treatment above must NOT be applied here. Doing so silently breaks
547
+ # any refinement sitting BEFORE a conversion:
548
+ # `Date.where(year: 2024) >> Date.transform(::String)` is
549
+ # `And(Intersection(Date, AVM), Function)`, and flattening across the And puts
550
+ # the `year` check last — after the Date has become a String, where it can
551
+ # never pass, so the decoder rejects every input.
552
+ #
553
+ # A refinement child is left exactly where it is rather than visited: it
554
+ # carries no encodable type (see #pure_refinement?), so the codec has nothing
555
+ # to rewrite in it, and it validates whatever the step before it produced.
556
+ #
557
+ # DECODE rewrites AT MOST ONE data step — the first facing the wire. `And(a, b)`
558
+ # feeds the input to `a`, and `b` receives what `a` produced, so once `a` accepts
559
+ # the encoded form while still producing what it produced, `b` needs nothing.
560
+ # Rewriting it too decodes twice: #bridge_input splices `b`'s own decode step in
561
+ # front, the already-decoded value hits a step expecting the encoded form, and the
562
+ # pipeline rejects everything. A step only counts as having consumed the wire if
563
+ # its rewrite actually CHANGED it.
564
+ def visit_composition(type, path)
565
+ left, right = type.children
566
+ l = pure_refinement?(left) ? left : visit(left, path)
567
+ # `right` receives what `left` produced, so it faces the wire only when
568
+ # `left`'s rewrite left it unchanged.
569
+ wire_consumed = @direction == :decode && !l.equal?(left)
570
+ r = pure_refinement?(right) || wire_consumed ? right : visit(right, path)
571
+
572
+ l.equal?(left) && r.equal?(right) ? type : Conjunction.build(l, r)
573
+ end
574
+
575
+ # A pure refinement carries no encodable type — it filters the adjacent
576
+ # type's values. A baseless Constraint refining a sibling qualifies, but a
577
+ # base-type Constraint — `Types::Date` IS `Constraint(::Date)` — is data an
578
+ # encoder must see (eg. in `Types::Date.where(year: ...)` ==
579
+ # `And(Constraint(::Date), AVM)`), and so is a baseless Module gate, which
580
+ # names a type rather than filtering one.
581
+ def pure_refinement?(child)
582
+ case child
583
+ when AttributeValueMatch, ValueClass, Not then true
584
+ when Constraint then child.base.nil? && !child.matcher.is_a?(::Module)
585
+ else false
586
+ end
587
+ end
588
+
589
+ # Recursive/self-referential types rewrite lazily: the memo ties the knot
590
+ # (all references to one Deferred share one rewritten Deferred), and any
591
+ # unmatched-type error inside the body surfaces at first resolution.
592
+ def visit_deferred(type, path)
593
+ @deferred_memo[type] ||= Deferred.new(-> { visit(type.type, path) })
594
+ end
595
+
596
+ def noop_or_fail(type, path)
597
+ if noop?(type)
598
+ return type if @direction == :decode
599
+
600
+ # Encode: the value at this position is the type's OUTPUT — pass
601
+ # that through, so a coercion field is not re-run on parsed values.
602
+ out = Plumb::Subtyping.resolved_output(type)
603
+ out.equal?(type) ? type : out
604
+ else
605
+ raise Plumb::TypeError,
606
+ "cannot apply #{@codec.inspect} (#{@direction}) to #{@root.inspect}: " \
607
+ "#{@codec.at_path(path)} (#{type.inspect}) matches no encoder and is not covered by its noop types. " \
608
+ 'Register an encoder for it, or declare it with .noop.'
609
+ end
610
+ end
611
+
612
+ end
613
+
614
+ # ------------------------------------------------------------------
615
+ # Shared, format-neutral encoders — string representations standardized
616
+ # by ISO 8601 (dates, times) and RFC 3986 (URIs) — registered by both
617
+ # built-in codecs, and usable per-field in any schema. Their input types
618
+ # carry `format:` metadata, so generated JSON Schemas describe the fields
619
+ # as eg. `{type: "string", format: "date"}`.
620
+ # ------------------------------------------------------------------
621
+
622
+ TIME_EXPR = /\A\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
623
+ # Scheme-prefixed URI strings, per RFC 3986 — URI.parse alone is too
624
+ # permissive (a blank string is a valid URI).
625
+ URI_EXPR = /\A[a-z][a-z0-9+\-.]*:/i
626
+ # Decimal or scientific notation — Float#to_s emits scientific for very
627
+ # small/large magnitudes ("1.0e-05"), so the input type must accept what
628
+ # encode produces or a valid Float fails its own round-trip.
629
+ FLOAT_EXPR = /\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/
630
+
631
+ # Date <=> ISO 8601 date string ("2024-01-30").
632
+ class DateEncoder < Encoder[Types::String[/\A\d{4}-\d{2}-\d{2}\z/].metadata(format: 'date') => Types::Date]
633
+ def encode(date) = date.iso8601
634
+ def decode(str) = ::Date.parse(str)
635
+ end
636
+
637
+ # Time <=> ISO 8601 date-time string ("2024-08-30T20:15:23Z").
638
+ class TimeEncoder < Encoder[Types::String[TIME_EXPR].metadata(format: 'date-time') => Types::Time]
639
+ def encode(time) = time.iso8601
640
+ def decode(str) = ::Time.parse(str)
641
+ end
642
+
643
+ # Symbol <=> String. Ruby data structures use Symbols for enum-ish
644
+ # values (`status: :active`); the encoded form carries them as strings.
645
+ # A narrowed field (`Types::Symbol[:draft, :published]`) keeps its check.
646
+ class SymbolEncoder < Encoder[Types::String => Types::Symbol]
647
+ def encode(sym) = sym.to_s
648
+ def decode(str) = str.to_sym
649
+ end
650
+
651
+ # BigDecimal <=> canonical decimal string ("1.5"). A string, not a
652
+ # number: decimals exist to avoid float precision loss, and JSON numbers
653
+ # are floats. In Codec::JSON this also keeps Decimal fields from silently
654
+ # noop-passing via the Numeric noop (a BigDecimal is not JSON-native).
655
+ class DecimalEncoder < Encoder[Types::String[FLOAT_EXPR] => Types::Decimal]
656
+ def encode(dec) = dec.to_s('F')
657
+ def decode(str) = BigDecimal(str)
658
+ end
659
+
660
+ # URI <=> scheme-prefixed URI string. The narrower HTTP/File variants win
661
+ # most-specific matching for fields typed as such; the produced value is
662
+ # validated against the declared URI class.
663
+ class URIEncoder < Encoder[Types::String[URI_EXPR].metadata(format: 'uri') => Types::URI::Generic]
664
+ def encode(uri) = uri.to_s
665
+ def decode(str) = ::URI.parse(str)
666
+ end
667
+
668
+ # Re-parameterized subclasses: same input type and methods, narrower
669
+ # output — the produced URI is validated against the declared class.
670
+ HTTPURIEncoder = URIEncoder[URIEncoder.input_type => Types::URI::HTTP]
671
+ FileURIEncoder = URIEncoder[URIEncoder.input_type => Types::URI::File]
672
+
673
+ # Range <=> a JSON object `{ from:, to:, exclusive: }`. Generic over the
674
+ # range's member type: it matches ANY `Range[member]` (its output type is
675
+ # the Range top), and #input_type_for builds the input from the matched
676
+ # member — so a `Range[Date]` serializes its endpoints as ISO strings, a
677
+ # `Range[Integer]` as JSON numbers, etc., because the codec rewrites those
678
+ # member-typed `from`/`to` fields through itself. `from`/`to` are nullable
679
+ # for beginless/endless ranges; `exclusive` records whether the end is
680
+ # excluded (`1...5` vs `1..5`).
681
+ class RangeEncoder < Encoder[
682
+ Types::Hash[
683
+ from: Types::Any.nullable,
684
+ to: Types::Any.nullable,
685
+ exclusive: Types::Boolean
686
+ ] => Types::Range
687
+ ]
688
+ # Specialize the input to the matched range's member type. A non-Range
689
+ # match (should not happen — only RangeClass is a subtype of the Range
690
+ # top) falls back to the generic declared input.
691
+ def self.input_type_for(matched)
692
+ return input_type unless matched.is_a?(Plumb::RangeClass)
693
+
694
+ member = matched.children.first
695
+ Types::Hash[
696
+ from: member.nullable,
697
+ to: member.nullable,
698
+ exclusive: Types::Boolean
699
+ ]
700
+ end
701
+
702
+ def encode(range)
703
+ { from: range.begin, to: range.end, exclusive: range.exclude_end? }
704
+ end
705
+
706
+ def decode(hash)
707
+ ::Range.new(hash[:from], hash[:to], hash[:exclusive])
708
+ end
709
+ end
710
+
711
+ # A base codec for JSON-like targets: the scalars native to JSON pass
712
+ # through (structured containers are handled structurally by the rewrite;
713
+ # the bare Hash/Array tops cover untyped ones), and Dates/Times/URIs map
714
+ # to their standard string forms. Subclass it and register encoders:
715
+ #
716
+ # class MyCodec < Plumb::Codec::JSON
717
+ # encoder MoneyEncoder
718
+ # end
719
+ #
720
+ # A subclass encoder for an equivalent type (eg. its own Date encoder)
721
+ # takes precedence over the built-in one.
722
+ class JSON < self
723
+ noop Types::String, Types::Numeric, Types::Integer, Types::Float,
724
+ Types::True, Types::False, Types::Nil,
725
+ Types::Hash, Types::Array
726
+
727
+ encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
728
+ encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
729
+ encoder RangeEncoder
730
+ end
731
+
732
+ # A base codec for string-based formats — HTML forms, query strings, CSV
733
+ # cells — where EVERY value arrives as a string. Unlike Codec::JSON there
734
+ # are almost no native scalars: strings pass through, untyped containers
735
+ # recurse structurally (Rack-style nested params), and everything else
736
+ # maps through an encoder whose input type is a strictly-patterned string.
737
+ #
738
+ # Config = Types::Hash[port: Types::Integer, active: Types::Boolean, on: Types::Date]
739
+ # decoder, encoder = Plumb::Codec::Forms.for(Config)
740
+ # decoder.parse({ port: '80', active: '1', on: '2024-01-30' })
741
+ # # => { port: 80, active: true, on: Date(2024-01-30) }
742
+ class Forms < self
743
+ INTEGER_EXPR = /\A-?\d+\z/
744
+
745
+ class IntegerEncoder < Encoder[Types::String[INTEGER_EXPR] => Integer]
746
+ def encode(int) = int.to_s
747
+ def decode(str) = str.to_i
748
+ end
749
+
750
+ class FloatEncoder < Encoder[Types::String[FLOAT_EXPR] => Float]
751
+ def encode(float) = float.to_s
752
+ def decode(str) = str.to_f
753
+ end
754
+
755
+ # For fields typed as the Numeric union. A more specific field (Integer,
756
+ # Float, Decimal) picks its own encoder via most-specific matching.
757
+ class NumericEncoder < Encoder[Types::String[FLOAT_EXPR] => Numeric]
758
+ def encode(num) = num.is_a?(BigDecimal) ? num.to_s('F') : num.to_s
759
+ # A fractional or scientific string is a Float; a plain integer literal
760
+ # is an Integer ("1e20".to_i would wrongly be 1).
761
+ def decode(str) = str.match?(/[.eE]/) ? str.to_f : str.to_i
762
+ end
763
+
764
+ # Booleans are two encoders: a field typed Types::Boolean is the
765
+ # `True | False` union underneath, and each branch matches its own.
766
+ class TrueEncoder < Encoder[(Types::String[/\Atrue\z/i] | '1') => Types::True]
767
+ def encode(_bool) = 'true'
768
+ def decode(_str) = true
769
+ end
770
+
771
+ class FalseEncoder < Encoder[(Types::String[/\Afalse\z/i] | '0') => Types::False]
772
+ def encode(_bool) = 'false'
773
+ def decode(_str) = false
774
+ end
775
+
776
+ # An empty or absent form field is nil — so `Types::Date | Types::Nil`
777
+ # decodes '' AND a literal nil (valueless params, eg. Rack's
778
+ # `parse_nested_query('x')`) to nil, and '2024-01-30' to a Date. The Nil
779
+ # in the input is left as a literal acceptance during input rewriting (it
780
+ # is noop-covered), not recursively re-encoded.
781
+ class NilEncoder < Encoder[(Types::String[''] | Types::Nil) => Types::Nil]
782
+ def encode(_nil) = ''
783
+ def decode(_value) = nil
784
+ end
785
+
786
+ noop Types::String, Types::Nil, Types::Hash, Types::Array
787
+
788
+ encoder IntegerEncoder, FloatEncoder, NumericEncoder
789
+ encoder TrueEncoder, FalseEncoder, NilEncoder
790
+ # Format-neutral string encoders, shared with Codec::JSON.
791
+ encoder DateEncoder, TimeEncoder, SymbolEncoder, DecimalEncoder
792
+ encoder URIEncoder, HTTPURIEncoder, FileURIEncoder
793
+ end
794
+ end
795
+ end