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.
- checksums.yaml +4 -4
- data/README.md +887 -64
- data/bench/compare_dry_schema.rb +79 -0
- data/bench/compare_dry_types.rb +37 -0
- data/bench/compare_parametric_schema.rb +2 -80
- data/bench/dry_schema_hash.rb +103 -0
- data/bench/dry_types_hash.rb +125 -0
- data/bench/json_schema_profile.rb +107 -0
- data/bench/plumb_hash.rb +17 -11
- data/bench/results_allocations.rb +137 -0
- data/bench/sample_data.rb +78 -0
- data/examples/command_objects.rb +1 -1
- data/examples/concurrent_downloads.rb +16 -9
- data/examples/event_registry.rb +6 -1
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +63 -6
- data/lib/plumb/any_class.rb +12 -2
- data/lib/plumb/array_class.rb +133 -25
- data/lib/plumb/attribute_value_match.rb +41 -1
- data/lib/plumb/attributes.rb +59 -19
- data/lib/plumb/codec.rb +886 -0
- data/lib/plumb/composable.rb +451 -39
- data/lib/plumb/conjunction.rb +50 -0
- data/lib/plumb/constraint.rb +234 -0
- data/lib/plumb/covariant_fusion.rb +46 -0
- data/lib/plumb/decorator.rb +12 -22
- data/lib/plumb/deferred.rb +13 -5
- data/lib/plumb/disjunction.rb +112 -0
- data/lib/plumb/encoder.rb +207 -0
- data/lib/plumb/function.rb +347 -0
- data/lib/plumb/hash_class.rb +339 -32
- data/lib/plumb/hash_map.rb +58 -14
- data/lib/plumb/implementation.rb +247 -0
- data/lib/plumb/interface_class.rb +21 -2
- data/lib/plumb/intersection.rb +47 -0
- data/lib/plumb/json_schema_visitor.rb +255 -36
- data/lib/plumb/key.rb +63 -13
- data/lib/plumb/mermaid_visitor.rb +129 -0
- data/lib/plumb/metadata.rb +10 -1
- data/lib/plumb/metadata_visitor.rb +36 -34
- data/lib/plumb/never_class.rb +38 -0
- data/lib/plumb/node_mapper.rb +97 -0
- data/lib/plumb/not.rb +34 -2
- data/lib/plumb/optimizer.rb +444 -0
- data/lib/plumb/or.rb +26 -29
- data/lib/plumb/pipeline.rb +99 -11
- data/lib/plumb/policy.rb +17 -4
- data/lib/plumb/range_class.rb +46 -0
- data/lib/plumb/relation.rb +57 -0
- data/lib/plumb/result.rb +55 -23
- data/lib/plumb/semantic_matcher.rb +393 -0
- data/lib/plumb/static_class.rb +20 -1
- data/lib/plumb/stream_class.rb +28 -6
- data/lib/plumb/subtyping.rb +461 -0
- data/lib/plumb/tagged_hash.rb +45 -4
- data/lib/plumb/tuple_class.rb +21 -4
- data/lib/plumb/type_cache.rb +41 -0
- data/lib/plumb/type_registry.rb +71 -0
- data/lib/plumb/typed_step.rb +67 -0
- data/lib/plumb/types.rb +44 -43
- data/lib/plumb/union.rb +30 -0
- data/lib/plumb/value_class.rb +20 -1
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +20 -4
- data/lib/plumb.rb +90 -3
- metadata +30 -8
- data/lib/plumb/build.rb +0 -22
- data/lib/plumb/match_class.rb +0 -42
- data/lib/plumb/schema.rb +0 -195
- data/lib/plumb/step.rb +0 -27
- 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
|
data/lib/plumb/decorator.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/plumb/deferred.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
+
type.call(result)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
def type
|
|
21
26
|
@lock.synchronize do
|
|
22
|
-
@cached_type
|
|
23
|
-
|
|
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
|