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
@@ -14,14 +14,32 @@ module Plumb
14
14
  end
15
15
 
16
16
  ParseError = Class.new(::TypeError)
17
+ # Raised by Composable#>> when chaining two steps whose types are provably
18
+ # incompatible (the left's #output_type is not a subtype of the right's
19
+ # #input_type).
20
+ TypeError = Class.new(::TypeError)
17
21
  Undefined = UndefinedClass.new.freeze
18
22
 
19
23
  BLANK_STRING = ''
20
24
  BLANK_ARRAY = [].freeze
21
25
  BLANK_HASH = {}.freeze
22
- BLANK_RESULT = Result.wrap(Undefined)
23
26
  NOOP = ->(result) { result }
24
27
 
28
+ # Ruby's explicit conversion methods and the type each produces. Lets
29
+ # `#transform(:to_i)` expand to a typed transform to Integer (using `:to_i` as
30
+ # the callable), instead of spelling out `#transform(Integer, &:to_i)`.
31
+ COERCION_METHODS = {
32
+ to_s: ::String,
33
+ to_sym: ::Symbol,
34
+ to_i: ::Integer,
35
+ to_f: ::Float,
36
+ to_r: ::Rational,
37
+ to_c: ::Complex,
38
+ to_a: ::Array,
39
+ to_h: ::Hash,
40
+ to_proc: ::Proc
41
+ }.freeze
42
+
25
43
  module Callable
26
44
  def resolve(value = Undefined)
27
45
  call(Result.wrap(value))
@@ -49,6 +67,11 @@ module Plumb
49
67
  # define a #node_name method on the Composable instance
50
68
  # #node_name is used by Visitors to determine the type of node.
51
69
  def self.included(base)
70
+ # An anonymous class (`Class.new { include Composable }`) has no name to
71
+ # derive from: leave #node_name to the instance method below, which reads
72
+ # the class name at call time (by then it may have been assigned one).
73
+ return unless base.name
74
+
52
75
  nname = base.name.split('::').last
53
76
  nname.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
54
77
  nname.downcase!
@@ -81,7 +104,7 @@ module Plumb
81
104
 
82
105
  def inspect = name.to_s
83
106
 
84
- def node_name = self.class.name.split('::').last.to_sym
107
+ def node_name = (self.class.name || 'anonymous').split('::').last.to_sym
85
108
  end
86
109
 
87
110
  # Override #=== and #== for Composable instances.
@@ -99,15 +122,87 @@ module Plumb
99
122
  end
100
123
  end
101
124
 
125
+ # Compares nodes by exact class and children. Use `instance_of?`, not `is_a?`:
126
+ # a subclass may change behavior without changing #children, making parent/subclass
127
+ # equality asymmetric. Subtyping and optimization short-circuit on `==`, so that
128
+ # asymmetry could discard the narrower node before its behavioral guards run.
129
+ # @param other [Object]
130
+ # @return [Boolean]
102
131
  def ==(other)
103
- other.is_a?(self.class) && other.respond_to?(:children) && other.children == children
132
+ other.instance_of?(self.class) && other.respond_to?(:children) && other.children == children
133
+ end
134
+
135
+ # Subtype/subset operator. `a <= b` is true when every value described by
136
+ # `self` is also described by `other` (`other` may be a raw Ruby class or
137
+ # value; it is normalized). Delegates to the generic Plumb::Subtyping engine.
138
+ # @param other [Composable, Class, Object]
139
+ # @return [Boolean]
140
+ def <=(other)
141
+ Plumb::Subtyping.subtype?(self, other)
104
142
  end
143
+
144
+ # `self` is a supertype of `other`: every value of `other` is a value of
145
+ # `self`.
146
+ def >=(other)
147
+ Plumb::Subtyping.subtype?(Composable.wrap(other), self)
148
+ end
149
+
150
+ # Strict subtype / supertype.
151
+ def <(other) = (self <= other) && !(self >= other)
152
+ def >(other) = (self >= other) && !(self <= other)
153
+
154
+ # Leaf hook for Plumb::Subtyping.subtype?, called once the algebra
155
+ # (Intersection/Or/top, and the #subtype_identity projection that replaces a
156
+ # converting node with what it produces) has been peeled away. It must NOT
157
+ # delegate back to #<= (that would recurse); it recurses only through
158
+ # Plumb::Subtyping.subtype?.
159
+ #
160
+ # Default behaviour:
161
+ # 1. reflexive structural equality;
162
+ # 2. atomic leaves (a single raw matcher/value) compared via Ruby
163
+ # semantics (Plumb::Subtyping.atomic_subtype?);
164
+ # 3. covariant containers — same class with pairwise-subtype children,
165
+ # which covers Array/Tuple/HashMap/Stream and any custom container that
166
+ # exposes #children, for free.
167
+ #
168
+ # Override for bespoke leaf relations (see HashClass width/depth subtyping).
169
+ # @param other [Composable]
170
+ # @return [Boolean]
171
+ def subtype_of?(other)
172
+ return true if self == other
173
+
174
+ if Plumb::Subtyping.atomic?(self) && Plumb::Subtyping.atomic?(other)
175
+ # #children contains only the matcher, but a refinement also describes its
176
+ # base. Check both or an unrelated atomic value may pass on matcher overlap
177
+ # alone (for example, Value[5.0] against Integer[1..5]).
178
+ if other.respond_to?(:base) && other.base && !Plumb::Subtyping.subtype?(self, other.base)
179
+ return false
180
+ end
181
+
182
+ return Plumb::Subtyping.atomic_subtype?(children.first, other.children.first)
183
+ end
184
+
185
+ return false unless other.instance_of?(self.class)
186
+ return false if children.empty? || children.size != other.children.size
187
+
188
+ children.zip(other.children).all? { |c, o| Plumb::Subtyping.subtype?(c, o) }
189
+ end
190
+
191
+ # Mirror of #subtype_of?, for relations the *supertype* owns and the subtype
192
+ # can't know about (eg. Interface duck-typing: any type is a subtype of an
193
+ # Interface whose methods its values support). Consulted by
194
+ # Plumb::Subtyping.subtype? only after #subtype_of? declines. Default: no —
195
+ # only the subtype side decides. Recurse via Plumb::Subtyping.subtype?, never
196
+ # #<=. Override for bespoke supertype behaviour (see InterfaceClass).
197
+ # @param other [Composable]
198
+ # @return [Boolean]
199
+ def supertype_of?(other) = false
105
200
  end
106
201
 
107
202
  #  Composable mixes in composition methods to classes.
108
203
  # such as #>>, #|, #not, and others.
109
204
  # Any Composable class can participate in Plumb compositions.
110
- # A host object only needs to implement the Step interface `call(Result::Valid) => Result::Valid | Result::Invalid`
205
+ # A host object only needs to implement the Step interface `call(Result) => Result`
111
206
  module Composable
112
207
  include Callable
113
208
 
@@ -127,14 +222,22 @@ module Plumb
127
222
  #
128
223
  # @example
129
224
  # ten = Composable.wrap(10)
130
- # ten.resolve(10) # => Result::Valid
131
- # ten.resolve(11) # => Result::Invalid
225
+ # ten.resolve(10) # => a valid Result
226
+ # ten.resolve(11) # => an invalid Result
132
227
  #
133
228
  # @param callable [Object]
134
229
  # @return [Composable]
135
230
  def self.wrap(callable)
136
231
  if callable.is_a?(Composable)
137
232
  callable
233
+ elsif callable.respond_to?(:to_composable)
234
+ # The context-free resolution hook for objects that become a type on
235
+ # demand: an Encoder class resolves to its default (declared) direction; a
236
+ # Codec raises, having no type to become. Used in schema literals,
237
+ # `Array[Enc]`, `#/`, etc. The composition operators (#>>, #|, #&)
238
+ # consult #to_plumb_type BEFORE wrapping, so a composed operand can
239
+ # resolve against context and never reaches this branch.
240
+ wrap(callable.to_composable)
138
241
  elsif callable.is_a?(::Hash)
139
242
  HashClass.new(schema: callable)
140
243
  elsif callable.is_a?(::Array)
@@ -148,12 +251,31 @@ module Plumb
148
251
  end
149
252
  Types::Array[element_type]
150
253
  elsif callable.respond_to?(:call)
151
- Step.new(callable)
254
+ Function.opaque(callable)
255
+ elsif Constraint.literal_matcher?(callable)
256
+ # Equality literals use ValueClass; broader matchers use Constraint.
257
+ ValueClass.new(callable)
152
258
  else
153
- MatchClass.new(callable)
259
+ Constraint.new(callable)
154
260
  end
155
261
  end
156
262
 
263
+ # Resolve `other` against a composition context and wrap it as a
264
+ # Composable — the full normalization for the RIGHT operand of a
265
+ # composition operator. Consults the operand's #to_plumb_type hook when it
266
+ # has one (raw values — procs, hash literals — don't, and skip straight to
267
+ # wrapping), so callers never need a #respond_to? check. EVERY custom
268
+ # operator implementation should route its operand through here.
269
+ #
270
+ # @param other [Object] the right operand
271
+ # @param op [Symbol] the composition operator being resolved (:>>, :| or :&)
272
+ # @param left [Composable] the left operand
273
+ # @return [Composable]
274
+ def self.resolve_operand(other, op:, left:)
275
+ other = other.to_plumb_type(op:, left:) if other.respond_to?(:to_plumb_type)
276
+ wrap(other)
277
+ end
278
+
157
279
  # A helper to wrap a block in a Step that will defer execution.
158
280
  # This so that types can be used recursively in compositions.
159
281
  # @example
@@ -165,35 +287,228 @@ module Plumb
165
287
  Deferred.new(definition || block)
166
288
  end
167
289
 
290
+ def input_type = self
291
+ def output_type = self
292
+
293
+ # Composition-context resolution hook, consulted on the RIGHT operand of
294
+ # #>>, #| and #& before wrapping/type-checking — via
295
+ # Composable.resolve_operand, which any custom operator implementation
296
+ # should route its operand through. It lets an operand
297
+ # resolve itself against the composition context: an Encoder picks the
298
+ # direction to run in from what `left` produces; a Codec builds an encode
299
+ # rewrite of `left`'s output. The returned value replaces the operand in
300
+ # the composition. Default: identity — every ordinary type composes as
301
+ # itself.
302
+ #
303
+ # @param op [Symbol] the composition operator (:>>, :| or :&)
304
+ # @param left [Composable] the left operand
305
+ # @return [Composable]
306
+ def to_plumb_type(op:, left:) = self
307
+
308
+ # The type that carries this node's identity for the subtype relation. A
309
+ # value-preserving type IS its own identity (the default). A value-converting
310
+ # type is identified by what it *produces*, so it projects onto a DISTINCT
311
+ # type (see Function#subtype_identity => output_type): Plumb::Subtyping.subtype?
312
+ # reduces `a <= b` to `produced(a) <= b` before consulting the leaf hooks.
313
+ #
314
+ # CONTRACT: only return a value other than `self` when that value is a
315
+ # genuinely different node. Returning `self` here is a no-op (subtype? guards
316
+ # with `!equal?(self)`, so it simply won't reduce); returning a node whose own
317
+ # #subtype_identity loops back would recurse forever. This is the extension
318
+ # point for building custom transforming types that play well with subtyping
319
+ # WITHOUT subclassing Function.
320
+ def subtype_identity = self
321
+
322
+ # The type this step accepts as the consumer of a `left >> self` chain — the
323
+ # values its #call processes without rejecting outright. Defaults to what it
324
+ # takes as input (its resolved #input_type): right for plain matchers and for
325
+ # conversion/consumer types (Function, Stream, Pipeline — they accept their
326
+ # declared input, so they need no override). Two kinds of type override it:
327
+ # - a refinement (And): its #input_type is only the type the chain opens
328
+ # with and would drop the constraint it adds, so it accepts its *output*;
329
+ # - a Hash: it relaxes each field to what that field accepts.
330
+ # Consulted by Plumb::Subtyping when checking `#>>`.
331
+ def accepted_type = Plumb::Subtyping.resolved_input(self)
332
+
333
+ # Whether running this step twice in a row is the same as running it once,
334
+ # i.e. it validates without changing the value. Lets `#>>` drop a redundant
335
+ # `X >> X`. Default false; only types that never transform the value opt in
336
+ # (see Constraint). A transform must NOT be idempotent — `X >> X` would
337
+ # apply it twice.
338
+ def idempotent? = false
339
+
340
+ # Whether this type returns its input value UNCHANGED on success — a
341
+ # coreflexive refinement (a pure filter). Lets `#|` absorb a redundant
342
+ # branch (`Integer | Numeric == Numeric`) without dropping a coercion. See
343
+ # Optimizer.reduce_union, which memoizes this per frozen node in TypeCache.
344
+ # Default false; refinements opt in, transforms stay false. A covariant
345
+ # container (Array/Tuple/HashMap) preserves the value exactly when all its
346
+ # element/child types do — so `Array[Integer] >> Array[Numeric]` collapses
347
+ # like the scalar `Integer >> Numeric` — while a container that reshapes the
348
+ # value (a filtered map dropping entries, a record dropping undeclared keys)
349
+ # stays false. A stronger property than #idempotent? (value-preserving ⟹
350
+ # idempotent).
351
+ def value_preserving? = false
352
+
353
+ # Fuse `self >> other` into a single node when the runtime checks at the
354
+ # boundary between them are provably redundant, or nil when fusion doesn't
355
+ # apply. A reduction rung in Optimizer.reduce_step, so both `#>>` and `#/`
356
+ # reach it. Implemented by Function (transform fusion) and CovariantFusion
357
+ # (the functor law for containers).
358
+ def fuse_with(_other) = nil
359
+
360
+ # Can Function#fuse_with drop this node's boundary checks? Only the node
361
+ # knows: the answer is yes exactly when its #call runs the standard
362
+ # input -> fn -> output mapping, so the checks removed at a seam are checks
363
+ # it would really have run. Default false — a node opts in by saying so.
364
+ # @see Function#fusable_step?
365
+ def fusable_step? = false
366
+
367
+ # BOUNDARY ABSORPTION — the one-sided companions to #fuse_with, for a seam
368
+ # where only one side is a typed step and the other is a plain type. A typed
369
+ # step already RUNS its boundary types as steps (`result.map(input).map(fn)
370
+ # .map(output)`), so a neighbouring type can move into the matching slot and
371
+ # the node then does the same work in one hop instead of two.
372
+ #
373
+ # `self >> type` -> #absorb_output(type), asked of the LEFT (the step)
374
+ # `type >> self` -> #absorb_input(type), asked of the RIGHT (the step)
375
+ #
376
+ # Return the rebuilt node, or nil to decline. Implemented by Function (which
377
+ # owns the soundness conditions) and re-associated by And, so a step in the
378
+ # middle of a chain is reachable. Reached from Optimizer.reduce_step as its
379
+ # LAST rung: absorption only fires where no other reduction applies.
380
+ def absorb_output(_type) = nil
381
+ def absorb_input(_type) = nil
382
+
168
383
  # Chain two composable objects together.
169
384
  # A.K.A "and" or "sequence"
170
385
  # @example
171
386
  # Step1 >> Step2 >> Step3
172
387
  #
388
+ # Type-checks the composition by subsumption: everything `self` produces
389
+ # must be acceptable to `other` (`self`'s output a subtype of `other`'s
390
+ # input), else it raises Plumb::TypeError — eg. `String >> Integer`, or
391
+ # `Integer[0..40] >> Integer[2..10]` (the left can emit values the right
392
+ # rejects). To narrow a value, use `#[]` / `#transform(...)[...]` (a
393
+ # refinement is a runtime-checked cast, built directly and not subtype-
394
+ # checked). The check is permissive only where types are unknown: opaque
395
+ # steps (plain procs, transforms, narrowing matchers) report Any and opt out.
396
+ #
173
397
  # @param other [Composable]
398
+ # @raise [Plumb::TypeError] when `self`'s output is not a subtype of `other`'s input.
174
399
  # @return [And]
175
400
  def >>(other)
176
- And.new(self, Composable.wrap(other))
401
+ other = Composable.resolve_operand(other, op: :>>, left: self)
402
+ # `X >> X` is redundant for a value-preserving validator (eg. Types::String):
403
+ # validating the same value twice is the same as once. Gated on #idempotent?
404
+ # so transforms — where `X >> X` would apply the change twice — never collapse.
405
+ return self if idempotent? && self == other
406
+
407
+ Plumb::Subtyping.check_composable!(self, other)
408
+ # Drop what `other` re-asserts that `self` already guarantees. reduce_step
409
+ # folds a base-type gate into a Constraint chain (`Integer[0..100] >>
410
+ # Integer[-10..110]` -> `Integer[0..100]`, `::Integer` validated once);
411
+ # redundant_refinement? does the same for any value-preserving `other` that
412
+ # `self` already subsumes (`String.where(size: 3..10) >> .where(size: 0..)`
413
+ # -> the former). A non-redundant `other` (a transform, or a narrowing
414
+ # refinement) stays an And.
415
+ Plumb::Optimizer.rewrite_step(self, other)
416
+ end
417
+
418
+ # Compose like #>> but WITHOUT the strict subtype check — the escape hatch
419
+ # for chains the checker can't prove safe but you know are (eg. a narrowing
420
+ # like `Types::Integer / Types::Integer[1..10]`, or feeding a producer whose
421
+ # output you know the right side accepts). You assert the composition is
422
+ # valid; it is still runtime-checked when data flows through. Reduces and
423
+ # builds the same refinement as #>> (just skipping the build-time check), so
424
+ # the result participates in subtyping like any other refinement. The `/` reads as
425
+ # `Pathname#/` does — "join the next segment". When `self` is the Any top the
426
+ # right side stands alone, consistent with #[].
427
+ #
428
+ # @param other [Composable]
429
+ # @return [Composable]
430
+ def /(other)
431
+ constrain(Composable.wrap(other))
177
432
  end
178
433
 
179
434
  # Chain two composable objects together as a disjunction ("or").
435
+ # When one value-preserving branch subsumes the other (`Integer | Numeric`,
436
+ # or `X | X`), the union absorbs to the wider branch — see
437
+ # Optimizer.reduce_union. Functions/containers never reduce (they may accept
438
+ # inputs the survivor rejects), so coercion unions are preserved.
180
439
  #
181
440
  # @param other [Composable]
182
- # @return [Or]
441
+ # @return [Composable]
183
442
  def |(other)
184
- Or.new(self, Composable.wrap(other))
443
+ other = Composable.resolve_operand(other, op: :|, left: self)
444
+ return self if other.is_a?(NeverClass) # X | Never == X
445
+
446
+ Plumb::Optimizer.rewrite_union(self, other)
447
+ end
448
+
449
+ # Intersection ("and"/meet) — the symmetric dual of #|. Builds the greatest
450
+ # lower bound of `self` and `other`: the type describing values that satisfy
451
+ # BOTH. Unlike #>>, it is order-independent and not subtype-checked. The
452
+ # reducer (Subtyping.intersect) narrows where it can — intersecting Ranges/
453
+ # Sets (`Integer[2..] & Integer[0..100]` == `Integer[2..100]`), covariant
454
+ # containers, and distributing over unions — and collapses a PROVABLY-empty
455
+ # intersection to `Types::Never` (`Integer[2..10] & Integer[11..100]`,
456
+ # `String & Integer`). When it can prove neither a narrowing nor emptiness it
457
+ # falls back to `Conjunction.build` — a runtime intersection where both sides
458
+ # must pass.
459
+ #
460
+ # @param other [Composable]
461
+ # @return [Composable]
462
+ def &(other)
463
+ other = Composable.resolve_operand(other, op: :&, left: self)
464
+ Plumb::Subtyping.intersect(self, other) || Conjunction.build(self, other)
185
465
  end
186
466
 
187
467
  # Transform value. Requires specifying the resulting type of the value after transformation.
188
468
  # @example
189
469
  # Types::String.transform(Types::Symbol, &:to_sym)
190
470
  #
191
- # @param target_type [Class] what type this step will transform the value to
471
+ # Shorthand: a single conversion symbol (see COERCION_METHODS) expands to a
472
+ # typed transform to the method's result type, using the symbol as the
473
+ # callable. When the input's base Ruby type is known, it also validates that
474
+ # the type actually responds to the method.
475
+ # @example
476
+ # Types::String.transform(:to_i) # => Transform to Integer, via :to_i
477
+ # Types::Integer.transform(:to_sym) # raises: Integer has no #to_sym
478
+ #
479
+ # @param target_type [Class, Symbol] the output type, or a conversion symbol
192
480
  # @param callable [#call, nil] a callable that will be applied to the value, or nil if block provided
193
481
  # @param block [Proc] a block that will be applied to the value, or nil if callable provided
194
- # @return [And]
482
+ # @return [Function]
195
483
  def transform(target_type, callable = nil, &block)
196
- self >> Transform.new(target_type, callable || block)
484
+ if target_type.is_a?(::Symbol) && callable.nil? && block.nil? && (out = COERCION_METHODS[target_type])
485
+ return coercion_transform(target_type, out)
486
+ end
487
+
488
+ # Explicit output type + a coercion symbol as the callable
489
+ # (eg. `#transform(::Numeric, :to_f)`). Since the symbol's result type is
490
+ # known, we can compare it against the declared output at composition time:
491
+ # - `ret <= target_type` => the produced value is always within the
492
+ # declared type, so the runtime output check is redundant (guaranteed).
493
+ # - `ret` and `target_type` disjoint (neither is a subtype of the other)
494
+ # => no value can be an instance of both, so the transform would reject
495
+ # EVERY input. That's a composition error, not a runtime one — raise now.
496
+ # - otherwise (`target_type < ret`, a genuine narrowing) => may or may not
497
+ # hold at runtime; leave the output check to do its job.
498
+ if callable.is_a?(::Symbol) && block.nil? && (ret = COERCION_METHODS[callable])
499
+ guaranteed = false
500
+ if target_type.is_a?(::Module)
501
+ if ret <= target_type
502
+ guaranteed = true
503
+ elsif !(target_type <= ret)
504
+ raise ArgumentError,
505
+ ":#{callable} produces a #{ret}, which is never a #{target_type}"
506
+ end
507
+ end
508
+ return transform_step(target_type, callable.to_proc, guaranteed:)
509
+ end
510
+
511
+ transform_step(target_type, callable || block || Plumb::NOOP)
197
512
  end
198
513
 
199
514
  # Pass the value through an arbitrary validation
@@ -202,9 +517,11 @@ module Plumb
202
517
  #
203
518
  # @param errors [String] error message to use when validation fails
204
519
  # @param block [Proc] a block that will be applied to the value
205
- # @return [And]
520
+ # @return [Constraint]
206
521
  def check(errors = 'did not pass the check', &block)
207
- self >> MatchClass.new(block, error: errors, label: errors)
522
+ # A refinement, not a sequence: build the matcher over `self` as its base so
523
+ # the checked value keeps `self`'s type (`User.check { … }` is still a User).
524
+ Constraint.new(block, base: self, error: errors, label: errors)
208
525
  end
209
526
 
210
527
  # Return a new Step with added metadata, or build step metadata if no argument is provided.
@@ -253,7 +570,7 @@ module Plumb
253
570
  # @param value [Object]
254
571
  # @rerurn [And]
255
572
  def value(val)
256
- self >> ValueClass.new(val)
573
+ constrain(ValueClass.new(val))
257
574
  end
258
575
 
259
576
  # Alias of `#[]`
@@ -262,12 +579,47 @@ module Plumb
262
579
  # email = Types::String['@']
263
580
  #
264
581
  # @param args [Array<Object>]
265
- # @return [And]
582
+ # @return [Constraint]
266
583
  def match(*args)
267
- self >> MatchClass.new(*args)
268
- end
584
+ # A refinement returns a base-carrying Constraint directly (not an
585
+ # `And(self, matcher)`): the matcher records `self` as its base, so it
586
+ # subtypes and composes as "a `self` narrowed by the matcher". When `self`
587
+ # is the Any top the matcher stands alone (`Any[String]` == the String
588
+ # matcher), matching the collapsing `AnyClass#>>` provides. Routed through
589
+ # Constraint.narrow so stacked Range refinements intersect
590
+ # (`Integer[0..100][10..]` == `Integer[10..100]`).
591
+ base = is_a?(AnyClass) ? nil : self
592
+ # A bare equality literal uses ValueClass so `Any[5]` and `Value[5]` share
593
+ # one subtype identity. A based literal remains a Constraint.
594
+ return ValueClass.new(args.first) if base.nil? && args.size == 1 && Constraint.literal_matcher?(args.first)
595
+
596
+ Constraint.narrow(base, *args)
597
+ end
598
+
599
+ # Sugar over #match: a splatted list of values becomes a Set membership
600
+ # matcher, so `Integer[1, 2, 3]` == `Integer[Set[1, 2, 3]]` (and composes /
601
+ # reduces like any Set constraint). A single argument is used as-is — a Range,
602
+ # Regexp, Set, class or literal value.
603
+ # @example
604
+ # Types::String['a', 'b', 'c'] # one of these three strings
605
+ def [](*args) = match(args.size > 1 ? ::Set.new(args) : args.first)
606
+
607
+ # Narrow `self` with a constraint. A constraint refines rather than
608
+ # sequences a new type, so this bypasses the #>> composition type-check
609
+ # (eg. `Generic[::URI::HTTP]` narrows a URI to an HTTP URI). When `self` is
610
+ # the Any top type the constraint stands alone (`Any[::String]` == the
611
+ # String matcher), preserving the collapsing that `AnyClass#>>` provides.
612
+ # Applies the same base-type reduction as #>> (see Optimizer.reduce_step), so
613
+ # `Integer[0..40] / Integer[2..10]` re-parents to `Integer[0..40][2..10]`
614
+ # rather than re-checking `::Integer`. `reduce_step` bails for a non-Constraint
615
+ # constraint (eg. #value's ValueClass), leaving the And.
616
+ # @param constraint [Composable]
617
+ # @return [Composable]
618
+ private def constrain(constraint)
619
+ return constraint if is_a?(AnyClass)
269
620
 
270
- def [](val) = match(val)
621
+ Plumb::Optimizer.rewrite_refinement(self, constraint)
622
+ end
271
623
 
272
624
  #  Support #as_node.
273
625
  class Node
@@ -296,6 +648,17 @@ module Plumb
296
648
 
297
649
  def call(result) = type.call(result)
298
650
 
651
+ # A Node is a transparent wrapper (it only re-labels its node_name): it
652
+ # delegates type-flow to the wrapped type.
653
+ def input_type = type.input_type
654
+ def output_type = type.output_type
655
+ def value_preserving? = type.value_preserving?
656
+
657
+ # Inspect as the wrapped type. Constant-assigned nodes (Types::Boolean,
658
+ # Email) are renamed by constant assignment and ignore this; a runtime node
659
+ # (eg. a factored :refined_union) would otherwise show "Composable::Node".
660
+ private def _inspect = type.inspect
661
+
299
662
  # Two nodes are equal when they wrap the same type with the same
300
663
  # node_name and args. The default Composable#== compares #children,
301
664
  # but a Node holds its identity in @node_name/@type/@args, so without
@@ -326,8 +689,14 @@ module Plumb
326
689
  # type = Types::Array.where(size: 1..10)
327
690
  # type = Types::String.where(bytesize: 1..10)
328
691
  #
329
- # @param attrs [Hash]
692
+ # @param attrs [Hash{Symbol, String => Object}] attribute name => matcher
330
693
  def where(attrs)
694
+ unless attrs.is_a?(::Hash) && !attrs.empty?
695
+ raise ArgumentError,
696
+ '#where expects a non-empty Hash of attribute => matcher ' \
697
+ "(eg. `where(size: 1..10)`), got #{attrs.inspect}"
698
+ end
699
+
331
700
  attrs.reduce(self) do |t, (name, value)|
332
701
  t >> AttributeValueMatch.new(t, name, value)
333
702
  end
@@ -344,11 +713,11 @@ module Plumb
344
713
  # Mode 1.b: #policy(:name) a single policy without an argument
345
714
  # Mode 2: #policy(p1: value, p2: value) multiple policies with arguments
346
715
  # The latter mode will be expanded to multiple #policy calls.
347
- # @return [Step]
716
+ # @return [Composable]
348
717
  def policy(*args, &blk)
349
718
  case args
350
719
  in [::Symbol => name, *rest] # #policy(:name, arg)
351
- types = Array(metadata[:type]).uniq
720
+ types = Plumb.resolve_base_types(output_type).uniq
352
721
 
353
722
  bargs = [self]
354
723
  arg = Undefined
@@ -385,7 +754,45 @@ module Plumb
385
754
  # @param factory_method [Symbol] method to call on the class to instantiate it.
386
755
  # @return [And]
387
756
  def build(cns, factory_method = :new, &block)
388
- self >> Build.new(cns, factory_method:, &block)
757
+ # Without a block the callable is a fresh lambda per call, so name what the
758
+ # step actually IS — the (class, factory method) pair — as its identity.
759
+ transform_step(cns, block || ->(value) { cns.send(factory_method, value) },
760
+ identity: block || [cns, factory_method])
761
+ end
762
+
763
+ # Build a Function that validates the input (self), applies a value-level
764
+ # callable, and declares `target_type` as the (validated) output type.
765
+ # @param identity [Object, nil] what the step IS, for Function#==. Defaults to
766
+ # `callable`; pass it explicitly when `callable` is itself built fresh here
767
+ # and so would differ between two identical constructions (see #build).
768
+ private def transform_step(target_type, callable, guaranteed: false, identity: nil)
769
+ klass = guaranteed ? GuaranteedFunction : Function
770
+ # Flip the cursor in place with the transformed value — the transform owns
771
+ # the result it is handed (the argument is evaluated first, reading the
772
+ # pre-transform value), so no fresh Result is needed.
773
+ #
774
+ # The wrapper lambda is new on every call, so it cannot serve as the node's
775
+ # identity — `callable` (the caller's own) does.
776
+ klass.new(self, Composable.wrap(target_type),
777
+ ->(result) { result.valid!(callable.call(result.value)) },
778
+ identity: identity || callable)
779
+ end
780
+
781
+ # Expand `#transform(:to_i)` into a typed transform to `output_type`, using
782
+ # the conversion symbol itself as the callable. If the input's base Ruby
783
+ # type(s) can be resolved, validate that they define the method (so
784
+ # `Types::Integer.transform(:to_sym)` fails loudly at build time).
785
+ private def coercion_transform(method_name, output_type)
786
+ bases = Plumb.resolve_base_types(self)
787
+ unless bases.empty? || bases.all? { |k| k.is_a?(::Module) && k.method_defined?(method_name) }
788
+ raise Plumb::TypeError,
789
+ "cannot #transform(#{method_name.inspect}): " \
790
+ "#{bases.map(&:inspect).join(' / ')} does not define ##{method_name}"
791
+ end
792
+
793
+ # The output type IS the coercion method's result type, so the produced
794
+ # value is guaranteed to match — skip the runtime output check.
795
+ transform_step(output_type, method_name.to_proc, guaranteed: true)
389
796
  end
390
797
 
391
798
  # Always return a static value, regardless of the input.
@@ -398,12 +805,6 @@ module Plumb
398
805
  # @param value [Object]
399
806
  # @return [And]
400
807
  def static(value)
401
- my_type = Array(metadata[:type]).first
402
- unless my_type.nil? || value.instance_of?(my_type)
403
- raise ArgumentError,
404
- "can't set a static #{value.class} value for a #{my_type} step"
405
- end
406
-
407
808
  StaticClass.new(value) >> self
408
809
  end
409
810
 
@@ -419,7 +820,9 @@ module Plumb
419
820
  generator ||= block
420
821
  raise ArgumentError, 'expected a generator' unless generator.respond_to?(:call)
421
822
 
422
- Step.new(->(r) { r.valid(generator.call) }, 'generator') >> self
823
+ Function.opaque(inspect: 'generator', identity: [:generate, generator]) do |r|
824
+ r.valid(generator.call)
825
+ end >> self
423
826
  end
424
827
 
425
828
  # Build a Plumb::Pipeline with this object as the starting step.
@@ -445,18 +848,29 @@ module Plumb
445
848
  JSONSchemaVisitor.call(self, root:)
446
849
  end
447
850
 
851
+ # Render this composition as a Mermaid `flowchart`. `>>` becomes sequential
852
+ # arrows and `|` becomes a fork. See Plumb::MermaidVisitor.
853
+ # @option direction [String] flowchart direction ('LR', 'TB', …)
854
+ # @return [String]
855
+ def to_mermaid(direction: 'LR')
856
+ MermaidVisitor.call(self, direction:)
857
+ end
858
+
448
859
  # Build a step that will invoke one or more methods on the value.
449
860
  # Ex 1: Types::String.invoke(:downcase)
450
861
  # Ex 2: Types::Array.invoke(:[], 1)
451
862
  # Ex 3 chain of methods: Types::String.invoke([:downcase, :to_sym])
452
- # @return [Step]
863
+ # @return [Composable]
453
864
  def invoke(*args, &block)
454
865
  case args
455
866
  in [::Symbol => method_name, *rest]
456
- self >> Step.new(
457
- ->(result) { result.valid(result.value.public_send(method_name, *rest, &block)) },
458
- [method_name.inspect, rest.inspect].join(' ')
459
- )
867
+ label = [method_name.inspect, rest.inspect].join(' ')
868
+ # The block is new on each call, but the step it implements is determined
869
+ # by the method and its arguments — so name that as the identity, and two
870
+ # `invoke(:downcase)` steps stay #== (see Function#==).
871
+ self >> Function.opaque(inspect: label, identity: [:invoke, method_name, rest, block]) do |result|
872
+ result.valid(result.value.public_send(method_name, *rest, &block))
873
+ end
460
874
  in [Array => methods] if methods.all? { |m| m.is_a?(Symbol) }
461
875
  methods.reduce(self) { |step, method| step.invoke(method) }
462
876
  else
@@ -468,7 +882,5 @@ end
468
882
 
469
883
  require 'plumb/deferred'
470
884
  require 'plumb/attribute_value_match'
471
- require 'plumb/transform'
472
885
  require 'plumb/policy'
473
- require 'plumb/build'
474
886
  require 'plumb/metadata'