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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # The runtime shared by {Plumb::And} (sequential composition) and
5
+ # {Plumb::Intersection} (the lattice meet). Both execute as `left` then `right`;
6
+ # they differ only in how types flow, which each class defines.
7
+ #
8
+ # They must be separate nodes because the two roles disagree about identity: a
9
+ # COMPOSITION is a morphism, identified by what it PRODUCES (like a Function),
10
+ # while an INTERSECTION is a type, identified by itself and subject to the meet
11
+ # rule (`(a ∧ b) <= c` when either conjunct is). Applying the meet rule to a
12
+ # composition is unsound — it makes `String >> (String -> Integer)` a subtype of
13
+ # String as well as Integer, two disjoint types.
14
+ module Conjunction
15
+ # An Intersection exactly when neither side changes the value (a refinement:
16
+ # both sides narrow one value, order irrelevant); otherwise a pipeline whose
17
+ # ends differ, an And.
18
+ #
19
+ # The single decision point — every construction site routes through here, so
20
+ # the distinction is settled once rather than re-derived from
21
+ # `#value_preserving?` at each use site.
22
+ #
23
+ # @param left [Composable]
24
+ # @param right [Composable]
25
+ # @return [Intersection, And]
26
+ def self.build(left, right)
27
+ if Plumb::Subtyping.value_preserving?(left) && Plumb::Subtyping.value_preserving?(right)
28
+ Intersection.new(left, right)
29
+ else
30
+ And.new(left, right)
31
+ end
32
+ end
33
+
34
+ attr_reader :children, :input_type, :output_type
35
+
36
+ def call(result)
37
+ result.map(@left).map(@right)
38
+ end
39
+
40
+ # Rebuilds by RECLASSIFYING: a rewrite that swaps a check for a conversion turns
41
+ # a meet into a composition, and preserving the class would leave an Intersection
42
+ # lying about #value_preserving?, which every reduction gates on.
43
+ # @see Plumb::NodeMapper
44
+ def with_children(children) = Conjunction.build(children[0], children[1])
45
+
46
+ private def _inspect
47
+ %((#{@left.inspect} >> #{@right.inspect}))
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/composable'
4
+
5
+ module Plumb
6
+ class Constraint
7
+ include Composable
8
+
9
+ # Sentinel returned by `merge_matchers`/`intersect_ranges` when two same-kind
10
+ # matchers have a PROVABLY-EMPTY overlap (disjoint Ranges, empty Set
11
+ # intersection). Distinct from `nil`, which means "not the same knowable kind,
12
+ # or an incomputable overlap — leave the two matchers stacked". Callers map
13
+ # EMPTY to `Types::Never`, so `Integer[0..5][10..]` == `Integer[0..5] & Integer[10..]`
14
+ # == `Types::Never` (see .narrow and Subtyping.intersect_constraints).
15
+ EMPTY = ::Object.new
16
+ def EMPTY.inspect = 'Plumb::Constraint::EMPTY'
17
+ EMPTY.freeze
18
+
19
+ # Matcher kinds whose `#===` is just `#==` — they match exactly ONE value.
20
+ # Two distinct ones are therefore provably disjoint, and as a `#>>` consumer
21
+ # one accepts only its own value. Deliberately excludes Module/Range/Regexp/
22
+ # Set/proc: their `#===` is a type, membership or pattern test that matches
23
+ # many values (and `Set#===` is `#include?` on Ruby >= 3.1).
24
+ LITERAL_MATCHERS = [::String, ::Symbol, ::Numeric, ::TrueClass, ::FalseClass, ::NilClass].freeze
25
+
26
+ # The matcher-level form of #literal? for normalization callers that do not
27
+ # yet have a Constraint. Prefer #literal? on an existing Constraint.
28
+ #
29
+ # @param matcher [#===]
30
+ # @return [Boolean] whether `matcher` matches a single value by equality
31
+ def self.literal_matcher?(matcher) = SemanticMatcher.singleton?(matcher)
32
+
33
+ attr_reader :children, :base, :matcher
34
+
35
+ # @param matcher [#===] the value/type/predicate to match against
36
+ # @param base [Composable, nil] the type this matcher *refines*. `nil` for a
37
+ # root type matcher (eg. `Types::Integer` is `Constraint(::Integer)`); set for a
38
+ # refinement built via `#[]`/`#match`/`#check` (eg. `Integer[1..10]` is
39
+ # `Constraint(1..10, base: Types::Integer)`). Carrying the base lets a matcher
40
+ # answer subtyping locally — it is a subtype of whatever its base is — so no
41
+ # `And` wrapper (or transparency flag) is needed to preserve the base type.
42
+ def initialize(matcher = Undefined, base: nil, error: nil, label: nil)
43
+ raise ParseError, 'matcher must respond to #===' unless matcher.respond_to?(:===)
44
+
45
+ raise_if_incompatible!(base, matcher) if base
46
+
47
+ @matcher = matcher
48
+ @matcher_node = SemanticMatcher.wrap(matcher)
49
+ @base = base
50
+ @error = error.nil? ? build_error(matcher) : (error % matcher)
51
+ @label = matcher.is_a?(Class) ? matcher.inspect : "Constraint(#{label || @matcher.inspect})"
52
+ @children = [matcher].freeze
53
+ freeze
54
+ end
55
+
56
+ # Smart refinement constructor: builds `Constraint.new(matcher, base:)`, but
57
+ # when both `base` (a Constraint) and `matcher` are the same kind of knowable
58
+ # matcher (Ranges or Sets), it INTERSECTS them into one over `base`'s own base
59
+ # rather than stacking two checks — so `Integer[0..100][10..]` is
60
+ # `Integer[10..100]`, `Integer[Set[1,2,3]][Set[2,3,4]]` is `Integer[Set[2,3]]`,
61
+ # and `Integer[0..100] >> Integer[0..]` reduces to `Integer[0..100]`. Other
62
+ # matchers stack as before.
63
+ def self.narrow(base, matcher)
64
+ if base.is_a?(Constraint)
65
+ merged = merge_matchers(base.matcher, matcher)
66
+ return Types::Never if merged.equal?(EMPTY) # provably-empty overlap ⇒ bottom
67
+ return narrow(base.base, merged) if merged # recurse: base.base may be a type gate
68
+ end
69
+ new(matcher, base:)
70
+ end
71
+
72
+ # Intersection of two knowable matchers of the same kind. Returns the merged
73
+ # matcher on a non-empty overlap, `EMPTY` when the overlap is provably empty
74
+ # (disjoint Ranges / empty Set intersection — callers reduce it to Never), or
75
+ # `nil` to not merge (different kinds, or an incomputable Range overlap — leave
76
+ # the two matchers stacked). Public because AttributeValueMatch narrowing
77
+ # reuses it (see Subtyping) — an attribute constraint intersects its Range/Set
78
+ # values just like a Constraint.
79
+ def self.merge_matchers(a, b)
80
+ merged = SemanticMatcher.merge(a, b)
81
+ return EMPTY if merged.empty?
82
+ return merged.node.raw if merged.merged?
83
+
84
+ nil
85
+ end
86
+
87
+ # As the consumer of a `left >> self` chain:
88
+ # - a refinement (has a base), a Class/Module gate, or a literal matcher
89
+ # accepts exactly what it validates — itself — so `Integer >>
90
+ # Integer[1..10]` and `Any[5] >> Any[6]` are correctly rejected (narrow
91
+ # with `#[]` instead), and the default `#accepted_type` (its resolved
92
+ # input) is `self`;
93
+ # - a bare pattern matcher (regex/range/set/proc with no base) narrows
94
+ # arbitrary input over a domain it cannot name, so it reports Any and opts
95
+ # out of the check.
96
+ def input_type
97
+ return self if base || @matcher_node.nominal? || literal?
98
+
99
+ Types::Any
100
+ end
101
+
102
+ # Whether this constraint matches a SINGLE value, by equality — so two
103
+ # distinct ones are provably disjoint (see Subtyping#literal_value).
104
+ def literal? = @matcher_node.singleton?
105
+
106
+ # @return [Boolean] whether the base and matcher are safe to deduplicate
107
+ def idempotent? = @base.nil? || @base.idempotent?
108
+
109
+ # A converting base runs before the matcher, so this constraint consumes what
110
+ # the base accepts. Pure refinements accept themselves.
111
+ # @return [Composable]
112
+ def accepted_type
113
+ return self unless @base && !Plumb::Subtyping.value_preserving?(@base)
114
+
115
+ Plumb::Subtyping.accepted_type(@base)
116
+ end
117
+
118
+ # Projects a matcher over a conversion onto the narrowed output type. Without
119
+ # this, subtype checks would treat the node as consuming the value it produces.
120
+ # @return [Composable]
121
+ def subtype_identity
122
+ return self if @base.nil? || Plumb::Subtyping.value_preserving?(@base)
123
+
124
+ produced = Plumb::Subtyping.resolved_output(@base)
125
+ produced.equal?(@base) ? self : Constraint.narrow(produced, @matcher)
126
+ end
127
+
128
+ # A constraint may wrap a converting base; treating it as a pure filter would
129
+ # let reductions discard that conversion.
130
+ # @return [Boolean] whether the base and matcher preserve the input value
131
+ def value_preserving? = @base.nil? || Plumb::Subtyping.value_preserving?(@base)
132
+
133
+ # Structural subtyping. A matcher describes the set `base ∩ {x | matcher === x}`
134
+ # (base = everything when nil). `self <= other` when self's set is contained in
135
+ # each of other's conjuncts: within other's base (if any) AND within other's
136
+ # matcher. Against a non-matcher type, a refinement is a subtype of whatever
137
+ # its base is (like AttributeValueMatch).
138
+ def subtype_of?(other)
139
+ return true if self == other
140
+
141
+ if other.is_a?(Constraint)
142
+ within_base = other.base.nil? || Plumb::Subtyping.subtype?(self, other.base)
143
+ return true if within_base && within_matcher?(other.matcher)
144
+
145
+ # `self` is `base ∩ {matcher}` — a subset of `base` — so whenever the
146
+ # base alone is a subtype of `other`, so is `self`. This is the same
147
+ # fallback the non-Constraint branch below uses; it belongs here too
148
+ # because base types (`Types::String`, `Types::Date`) ARE Constraints,
149
+ # and `within_matcher?` can't peel a transparent base (a Metadata/Policy
150
+ # wrapper, eg. `String.metadata(...).present`) to see the guarantee.
151
+ base ? Plumb::Subtyping.subtype?(base, other) : false
152
+ elsif base
153
+ Plumb::Subtyping.subtype?(base, other)
154
+ else
155
+ false
156
+ end
157
+ end
158
+
159
+ # Is every value self describes matched by `m`? True if self's own matcher is
160
+ # within `m`, or self's base already guarantees it (eg. `Integer.check {}` is
161
+ # within `::Integer` because its base is Integer, even though its proc matcher
162
+ # tells us nothing).
163
+ protected def within_matcher?(m)
164
+ Plumb::Subtyping.atomic_subtype?(matcher, m) ||
165
+ (base.is_a?(Constraint) && base.within_matcher?(m))
166
+ end
167
+
168
+ # Identity label for matchers without structural equality.
169
+ protected def label = @label
170
+
171
+ # Compares matchers and bases. Fresh Procs lack structural equality, so Proc
172
+ # matchers use their caller-supplied label as stable identity.
173
+ # @param other [Object]
174
+ # @return [Boolean]
175
+ def ==(other)
176
+ return false unless other.instance_of?(self.class) && other.base == base
177
+
178
+ if matcher.is_a?(::Proc)
179
+ other.matcher.is_a?(::Proc) && other.label == label
180
+ else
181
+ other.matcher == matcher
182
+ end
183
+ end
184
+
185
+ def call(result)
186
+ # Only the base can invalidate the incoming result (callers — map/Or/resolve
187
+ # — always pass a valid one), so the `valid?` short-circuit is only needed
188
+ # when there IS a base. Root constraints skip straight to the matcher check,
189
+ # like a flat leaf. Reading @base directly (not the #base reader) also skips
190
+ # a dispatch on this hot path.
191
+ if @base
192
+ result = @base.call(result)
193
+ return result unless result.valid?
194
+ end
195
+
196
+ @matcher === result.value ? result : result.invalid!(errors: @error)
197
+ end
198
+
199
+ private
200
+
201
+ # A refinement whose matcher can never match a value of the base is a
202
+ # contradiction — an empty type, almost always a mistake — so reject it at
203
+ # build time (eg. `Types::String[1..20]`: no String is in an integer range).
204
+ # Only checked for matchers whose domain is knowable (Class/Range/Regexp/
205
+ # primitive literal); procs and custom `#===` objects are opaque, so allowed.
206
+ def raise_if_incompatible!(base, matcher)
207
+ base_types = Plumb.resolve_base_types(base)
208
+ return if base_types.empty? # unknown base (eg. a Data schema) — allow
209
+ overlap = Relation.any(base_types.map { |klass| matcher_may_match_relation(matcher, klass) })
210
+ return unless overlap.disproven?
211
+
212
+ raise Plumb::TypeError,
213
+ "cannot refine #{base.inspect} with #{matcher.inspect}: " \
214
+ "no #{base_types.map(&:inspect).join(' / ')} value can match it"
215
+ end
216
+
217
+ # Attempts to prove whether `matcher` overlaps a nominal domain. Unknown is
218
+ # intentionally accepted by the constructor and retained as a runtime check.
219
+ # @return [Relation]
220
+ def matcher_may_match_relation(matcher, klass)
221
+ SemanticMatcher.wrap(matcher).overlap(SemanticMatcher.wrap(klass))
222
+ end
223
+
224
+ def _inspect
225
+ return @label unless base
226
+
227
+ "#{base.inspect}[#{@matcher.inspect}]"
228
+ end
229
+
230
+ def build_error(matcher)
231
+ SemanticMatcher.error_message(matcher)
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # MAP FUSION for covariant containers — the second functor law as a rewrite rule.
5
+ # `Array[_]`, `Tuple[…]`, `Hash[K, _]` and `Stream[_]` are covariant functors, so
6
+ # mapping `f` then `g` is mapping `f >> g`:
7
+ #
8
+ # Array[f] >> Array[g] == Array[f >> g]
9
+ #
10
+ # The left traverses twice and materialises an intermediate copy; the right once. On
11
+ # a 50-element Array that is ~28% less time and 6 fewer allocations, growing with the
12
+ # collection. Hooked into `#fuse_with`, the container-level counterpart of
13
+ # {Plumb::Function}'s transform fusion.
14
+ #
15
+ # SOUNDNESS. Carries its OWN proof rather than trusting the caller's: `#>>`
16
+ # type-checks the containers first but `#/` deliberately does not, and both reach
17
+ # here. Every element pair must be provably composable.
18
+ #
19
+ # That guard is what keeps ERROR behaviour intact — the one thing fusion could
20
+ # change. Two passes report stage by stage (a first-stage failure means the second
21
+ # never runs), while one pass could surface a second-stage error for element 2
22
+ # beside a first-stage error for element 1. A provable boundary means the right
23
+ # element cannot reject what the left produced, so the two agree. An unprovable one
24
+ # (an opaque `#check`, a narrowing refinement) simply does not fuse.
25
+ module CovariantFusion
26
+ # @param other [Composable]
27
+ # @return [Composable, nil] the fused container, or nil to leave the pair alone
28
+ def fuse_with(other)
29
+ return nil unless self.class == other.class
30
+ return nil if children.empty? || children.size != other.children.size
31
+
32
+ pairs = children.zip(other.children)
33
+ return nil unless pairs.all? { |mine, theirs| fusable_boundary?(mine, theirs) }
34
+
35
+ with_children(pairs.map { |mine, theirs| mine >> theirs })
36
+ end
37
+
38
+ # Mirrors Function#fuse_with's check, using the same memoized projections.
39
+ private def fusable_boundary?(mine, theirs)
40
+ Plumb::Subtyping.subtype?(
41
+ Plumb::Subtyping.resolved_output(mine),
42
+ Plumb::Subtyping.accepted_type(theirs)
43
+ )
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'plumb/node_mapper'
4
+
3
5
  module Plumb
4
6
  # A class to help decorate all or some types in a
5
7
  # type composition.
@@ -12,6 +14,15 @@ module Plumb
12
14
  # type
13
15
  # end
14
16
  # end
17
+ #
18
+ # The block is called on EVERY node, bottom-up: sub-types are visited (and possibly
19
+ # replaced) before the block sees the node itself. Returning a node unchanged is a
20
+ # no-op, and an unchanged subtree comes back as the identical object.
21
+ #
22
+ # Traversal is {Plumb::NodeMapper}'s, so it reaches a record's fields, a container's
23
+ # element type and the inside of a Metadata / Policy / #as_node wrapper. It stops at
24
+ # a Constraint's base (rebuilding one would drop a custom `#check` message) and at a
25
+ # Deferred (forcing it would loop on a self-referential type).
15
26
  class Decorator
16
27
  def self.call(type, &block)
17
28
  new(block).visit(type)
@@ -24,32 +35,11 @@ module Plumb
24
35
  # @param type [Composable]
25
36
  # @return [Composable]
26
37
  def visit(type)
27
- type = case type
28
- when And
29
- left, right = visit_children(type)
30
- And.new(left, right)
31
- when Or
32
- left, right = visit_children(type)
33
- Or.new(left, right)
34
- when Not
35
- child = visit_children(type).first
36
- Not.new(child, errors: type.errors)
37
- when Policy
38
- child = visit_children(type).first
39
- Policy.new(type.policy_name, type.arg, child)
40
- else
41
- type
42
- end
43
-
44
- decorate(type)
38
+ decorate(NodeMapper.map(type) { |child| visit(child) })
45
39
  end
46
40
 
47
41
  private
48
42
 
49
- def visit_children(type)
50
- type.children.map { |child| visit(child) }
51
- end
52
-
53
43
  def decorate(type)
54
44
  @block.call(type)
55
45
  end
@@ -13,14 +13,23 @@ module Plumb
13
13
  # freeze
14
14
  end
15
15
 
16
+ # Deferred nodes use identity equality to avoid materializing recursive types.
17
+ # @param other [Object]
18
+ # @return [Boolean]
19
+ def ==(other) = equal?(other)
20
+
16
21
  def call(result)
17
- cached_type.call(result)
22
+ type.call(result)
18
23
  end
19
24
 
20
- private def cached_type
25
+ def type
21
26
  @lock.synchronize do
22
- @cached_type = @definition.call
23
- self.define_singleton_method(:cached_type) do
27
+ @cached_type ||= @definition.call
28
+ # Release the definition closure: it can capture large scopes (eg. a
29
+ # codec rewriter and the type graph it walked) that would otherwise
30
+ # stay reachable for the life of this node.
31
+ @definition = nil
32
+ self.define_singleton_method(:type) do
24
33
  @cached_type
25
34
  end
26
35
  @cached_type
@@ -28,4 +37,3 @@ module Plumb
28
37
  end
29
38
  end
30
39
  end
31
-
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # The runtime shared by {Plumb::Or} (left-biased choice) and {Plumb::Union} (the
5
+ # lattice join) — the dual of {Plumb::Conjunction}. Both try `left` and retry
6
+ # `right` with the original value on failure, and differ only in how types flow:
7
+ #
8
+ # - a CHOICE may have CONVERTING branches, so its ends genuinely differ:
9
+ # `Integer | String.transform(:to_i)` accepts a String but produces an Integer.
10
+ # - a UNION is a type — every branch returns its value untouched — so it is its
11
+ # own output type, needing no #value_preserving? recursion and no rebuild.
12
+ module Disjunction
13
+ # @param left [Composable]
14
+ # @param right [Composable]
15
+ # @return [Union, Or]
16
+ def self.build(left, right)
17
+ if Plumb::Subtyping.value_preserving?(left) && Plumb::Subtyping.value_preserving?(right)
18
+ Union.new(left, right)
19
+ else
20
+ Or.new(left, right)
21
+ end
22
+ end
23
+
24
+ attr_reader :children
25
+
26
+ # Identical for both nodes, which differ only in how types flow.
27
+ def initialize(left, right)
28
+ @left = Composable.wrap(left)
29
+ @right = Composable.wrap(right)
30
+ @children = [@left, @right].freeze
31
+ freeze
32
+ end
33
+
34
+ # (A | B).input_type == A.input_type | B.input_type — shared by both nodes.
35
+ #
36
+ # A Union cannot shortcut this to `self` the way it can #output_type, because a
37
+ # branch may ACCEPT more than it describes: a bare-matcher Constraint reports
38
+ # `input_type` Any, so a factored `String[/d/] | String[/c/]` consumes Any, and a
39
+ # Union claiming to consume only itself would fail `String >> that`.
40
+ #
41
+ # Rebuilt through .build, not the receiver's class: a disjunction may be a
42
+ # computation, but its projections are types, so
43
+ # `(String->Integer | Integer->String).input_type` is a Union and compares equal
44
+ # to a hand-written `String | Integer`.
45
+ #
46
+ # Lazy, or #initialize would recurse building its own io types. Returns self when
47
+ # both children are their own input type, so Subtyping.resolved_input converges on
48
+ # identity without allocating.
49
+ def input_type
50
+ l = @left.input_type
51
+ r = @right.input_type
52
+ l.equal?(@left) && r.equal?(@right) ? self : Disjunction.build(l, r)
53
+ end
54
+
55
+ # Rebuild around new branches, RECLASSIFYING by what they are.
56
+ # @see Conjunction#with_children for why this must not preserve the class.
57
+ def with_children(children) = Disjunction.build(children[0], children[1])
58
+
59
+ private def _inspect
60
+ %((#{@left.inspect} | #{@right.inspect}))
61
+ end
62
+
63
+ def call(result)
64
+ # Snapshot the input value: @left may flip the cursor to invalid in place,
65
+ # so we need the original to retry @right on the same object.
66
+ original = result.value
67
+
68
+ left_result = @left.call(result)
69
+ return left_result if left_result.valid?
70
+
71
+ # Capture left's errors before reusing the cursor — if @left mutated
72
+ # `result` in place, `left_result` IS `result` and the reset below would
73
+ # wipe them.
74
+ left_raw = left_result.errors
75
+
76
+ right_result = @right.call(result.reset(original))
77
+ return right_result if right_result.valid?
78
+
79
+ # Both branches failed. Combine the two error sets, then reuse right's
80
+ # already-invalid cursor in place rather than allocating another — a union can
81
+ # be expensive in composite ORed types. `right_result.errors` is read BEFORE
82
+ # #invalid! overwrites it.
83
+ merged = Disjunction.merge_errors(left_raw, right_result.errors)
84
+ right_result.invalid!(errors: merged)
85
+ end
86
+
87
+ # Combining the errors of failed ALTERNATIVES is a monoid: `nil` is the identity,
88
+ # concatenation the operation. ASSOCIATIVITY is the law that matters — `(A|B)|C`
89
+ # and `A|(B|C)` are the same set of alternatives and owe the same errors, so
90
+ # taking only `errors.first` from the right (as this once did) drops every
91
+ # alternative after the first in a right-nested union.
92
+ #
93
+ # Non-destructive: never appends to the left result's own array, which has already
94
+ # been handed out as an #errors value. Costs one extra Array, and only for three or
95
+ # more alternatives.
96
+ #
97
+ # ONLY for alternatives — a record's or array's errors are a Hash keyed by
98
+ # field/index, which is meaningful structure this is never applied to.
99
+ #
100
+ # @param left [Object, nil] the left alternative's errors
101
+ # @param right [Object, nil] the right alternative's errors
102
+ # @return [Object, nil]
103
+ def self.merge_errors(left, right)
104
+ return right if left.nil?
105
+ return left if right.nil?
106
+
107
+ merged = left.is_a?(::Array) ? left.dup : [left]
108
+ right.is_a?(::Array) ? merged.concat(right) : merged.push(right)
109
+ merged
110
+ end
111
+ end
112
+ end