plumb 0.0.18 → 0.2.0.beta.2

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