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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # Process-level memoization for computed type graphs (resolved input/output
5
+ # and accepted types — see Plumb::Subtyping). Keyed by type identity in
6
+ # WeakKeyMaps, so entries are pruned by GC together with their types. Only
7
+ # frozen types are cached: a mutable type (an unfrozen Pipeline, a Deferred)
8
+ # may change its answer over time, so it always recomputes.
9
+ module TypeCache
10
+ MAPS = {
11
+ resolved_input: ObjectSpace::WeakKeyMap.new,
12
+ resolved_output: ObjectSpace::WeakKeyMap.new,
13
+ accepted_type: ObjectSpace::WeakKeyMap.new,
14
+ value_preserving: ObjectSpace::WeakKeyMap.new
15
+ }.freeze
16
+
17
+ LOCK = Mutex.new
18
+
19
+ # Sentinel distinguishing "no entry" from a cached `false`/`nil` — WeakKeyMap#[]
20
+ # returns nil for both, so a truthiness check would never cache a falsy value
21
+ # (e.g. #value_preserving? is a boolean, false for every transform/container).
22
+ ABSENT = Object.new.freeze
23
+ private_constant :ABSENT
24
+
25
+ # Presence-based so cached `false`/`nil` are returned as hits, not recomputed.
26
+ #
27
+ # NOTE: compute OUTSIDE the lock — computations recurse back into #fetch
28
+ # (And/Or children, resolved_* chains) and a non-reentrant Mutex would
29
+ # deadlock. A race may compute the same pure value twice; the first write wins.
30
+ def self.fetch(slot, key)
31
+ return yield unless key.frozen?
32
+
33
+ map = MAPS.fetch(slot)
34
+ cached = LOCK.synchronize { map.key?(key) ? map[key] : ABSENT }
35
+ return cached unless cached.equal?(ABSENT)
36
+
37
+ value = yield
38
+ LOCK.synchronize { map.key?(key) ? map[key] : (map[key] = value) }
39
+ end
40
+ end
41
+ end
@@ -1,7 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
+ # Mix-in that turns a module into a namespace of named Plumb types: every
5
+ # {Composable} assigned to one of its constants is named after that constant,
6
+ # so it inspects under the module's path rather than as an anonymous type.
7
+ #
8
+ # There are two ways to attach it, and they cover different lifecycles:
9
+ #
10
+ # ## `extend TypeRegistry` — name a module's own constants
11
+ #
12
+ # Extend a module to make it a type registry. From that point, {#const_added}
13
+ # fires for each constant assigned on it and names the type in place. Nested
14
+ # modules are extended automatically, so naming continues down the tree.
15
+ #
16
+ # @example Defining your own registry
17
+ # module MyTypes
18
+ # extend Plumb::TypeRegistry
19
+ #
20
+ # # `String | Integer` builds an anonymous Or; assigning it names it.
21
+ # Name = Plumb::Types::String.present
22
+ # Score = Plumb::Types::Integer[0..100]
23
+ # end
24
+ #
25
+ # MyTypes::Name.inspect # => "MyTypes::Name"
26
+ # MyTypes::Score.inspect # => "MyTypes::Score"
27
+ #
28
+ # ## `include <a registry>` — re-home its types into your namespace
29
+ #
30
+ # Including a module that is already a registry triggers {#included}, which
31
+ # extends the host (so its future constants are named too) and copies the
32
+ # source's predefined types into the host under the host's own namespace.
33
+ # Composable types are `dup`ed so the shared originals are left untouched;
34
+ # nested namespace modules are rebuilt recursively.
35
+ #
36
+ # @example Inheriting an existing registry's types
37
+ # module MyTypes
38
+ # include Plumb::Types # Plumb::Types is itself a registry (see below)
39
+ #
40
+ # # `String`/`Integer` now resolve to MyTypes' own copies.
41
+ # Foo = String | Integer
42
+ # end
43
+ #
44
+ # MyTypes::String.inspect # => "MyTypes::String" (a copy, host-namespaced)
45
+ # MyTypes::Foo.inspect # => "MyTypes::Foo"
46
+ #
47
+ # {Plumb::Types} is the canonical registry: it does `extend TypeRegistry` so
48
+ # all its built-in types (`String`, `Integer`, `Lax::*`, …) are named, and
49
+ # so `include Plumb::Types` re-homes them into your own module.
50
+ #
51
+ # @note {#const_added} names the assigned instance *in place*. Assigning a
52
+ # type that composes/derives another (e.g. `Types::Integer[18..]`,
53
+ # `Types::String.present`) builds a fresh instance, so only the new
54
+ # constant is named. Aliasing an existing type directly
55
+ # (`Age = Types::Integer`) assigns the same shared instance and renames
56
+ # the original — assign a derived copy if you want an independent name.
4
57
  module TypeRegistry
58
+ # `Module#const_added` hook (Ruby >= 3.2). Fires whenever a constant is
59
+ # assigned on a host extended with this module. Names the assigned type
60
+ # instance after the constant, and extends nested modules so the naming
61
+ # continues down the tree.
62
+ #
63
+ # @param const_name [Symbol] the name of the just-assigned constant
64
+ # @return [void]
5
65
  def const_added(const_name)
6
66
  obj = const_get(const_name)
7
67
  case obj
@@ -13,6 +73,17 @@ module Plumb
13
73
  end
14
74
  end
15
75
 
76
+ # `Module#included` hook. Fires when a module carrying this registry (e.g.
77
+ # {Plumb::Types}) is included into a host. Copies every predefined type
78
+ # from the source into the host under the host's namespace, and extends the
79
+ # host so that constants assigned afterwards are named via {#const_added}.
80
+ #
81
+ # Composable types are `dup`ed before renaming so the shared originals are
82
+ # left untouched; nested namespace modules are rebuilt so their own
83
+ # constants are recursively re-homed under the host.
84
+ #
85
+ # @param host [Module] the module or class including the source
86
+ # @return [void]
16
87
  def included(host)
17
88
  host.extend TypeRegistry
18
89
  constants(false).each do |const_name|
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ # A TYPED STEP declares an #input_type, an #output_type and a `Result => Result`
5
+ # core, and its #call runs that core between the two checks.
6
+ #
7
+ # Two implementations exist, differing only in WHO OWNS THE STORAGE:
8
+ #
9
+ # - {Plumb::Function} — Plumb owns the object, so the declared pair and the
10
+ # callable are ivars behind plain readers.
11
+ # - {Plumb::Implementation::TypeInterface} — the HOST owns its object and its
12
+ # constructor, so the pair comes from the declaration and the core is reached
13
+ # through the host's private #_call.
14
+ #
15
+ # This module holds only what follows from the DECLARATION, and is therefore
16
+ # identical either way. Anything that depends on the storage (#call, #children,
17
+ # #fn, #identity, #==) stays with each implementation, as does #standard_call?.
18
+ #
19
+ # Include AFTER Composable, whose #subtype_identity / #accepted_type defaults
20
+ # these replace.
21
+ module TypedStep
22
+ # Declaring no types at all: both ends are Any (top), so neither check can fail
23
+ # and neither is worth dropping. A statement about the declared types only — a node
24
+ # that WRAPS a caller's callable says so through Function#wraps_callable?, since a
25
+ # wrapper that has absorbed a neighbouring type into a slot is not opaque.
26
+ def opaque? = input_type == Types::Any && output_type == Types::Any
27
+
28
+ # A conversion is identified for subtyping by what it PRODUCES — the input
29
+ # constraints do not carry through to the output.
30
+ # @see Plumb::Subtyping.subtype?
31
+ def subtype_identity = output_type
32
+
33
+ # As a `left >> self` consumer, a step accepts what its INPUT type accepts —
34
+ # not the input node verbatim. When that input is a Hash/container whose fields
35
+ # are themselves converting (eg. a codec's decode schema, whose `flags` field is
36
+ # a `String -> Integer` step), the accepted type is the input relaxed per field
37
+ # to the type it consumes. This is what lets an encode pipeline compose with the
38
+ # matching decode pipeline (`encode >> decode`): both meet at the same input
39
+ # type. Mirrors HashClass#accepted_type.
40
+ def accepted_type = Plumb::Subtyping.accepted_type(input_type)
41
+
42
+ # Does #call end by validating against #output_type? True for the plain
43
+ # `input -> fn -> output` mapping; false only where that check is provably
44
+ # redundant (see GuaranteedFunction). This is what tells Function#fuse_with
45
+ # whether a seam has an output check to drop, and whether the node it builds
46
+ # needs to keep one — replacing two `is_a?(GuaranteedFunction)` tests, which
47
+ # gave a non-Function step the right answer only by default.
48
+ #
49
+ # Defaults to true, the fail-safe direction: a wrong `true` keeps a redundant
50
+ # check (slower, still correct), a wrong `false` drops a necessary one.
51
+ def checks_output? = true
52
+
53
+ # Can Function#fuse_with drop this node's boundary checks? Only when #call is
54
+ # the canonical mapping for its kind — a node that replaced #call runs
55
+ # different checks, and nothing can be assumed about which of them a seam
56
+ # removes. Excluded when OPAQUE too: the only checks dropped would be no-ops,
57
+ # so there is nothing to win.
58
+ def fusable_step? = !opaque? && standard_call?
59
+
60
+ # Is #call still the canonical mapping described above? Only the node can
61
+ # answer, and each implementation answers against its own.
62
+ # @return [Boolean]
63
+ def standard_call?
64
+ raise NotImplementedError, "#{self.class} must define #standard_call?"
65
+ end
66
+ end
67
+ end
data/lib/plumb/types.rb CHANGED
@@ -19,11 +19,20 @@ module Plumb
19
19
  end
20
20
 
21
21
  # Generic options policy for all other types.
22
+ # An Array argument is treated as an enumeration of allowed values.
23
+ # Any other argument (Range, Regexp, Class, etc.) is delegated to a Match
24
+ # constraint, ie `type[opts]`, so it composes with the rest of the library
25
+ # (eg. a Range serializes to JSON Schema as `minimum`/`maximum`).
22
26
  # Usage:
23
27
  # type = Types::String.options(['a', 'b'])
28
+ # type = Types::Integer.options(10..20)
24
29
  policy :options do |type, opts|
25
- type.check("must be included in #{opts.inspect}") do |v|
26
- opts.include?(v)
30
+ if opts.is_a?(::Array)
31
+ type.check("must be included in #{opts.inspect}") do |v|
32
+ opts.include?(v)
33
+ end
34
+ else
35
+ type[opts]
27
36
  end
28
37
  end
29
38
 
@@ -87,9 +96,16 @@ module Plumb
87
96
  # Works with a block too:
88
97
  # date = Type::Any[Date].default { Date.today }
89
98
  #
99
+ # A generated default declares, and is checked against, `type`'s OUTPUT — not `type`
100
+ # itself, which would re-run a conversion on a value the block already produced
101
+ # (`Types::String.transform(::Integer).default { 10 }` generates the Integer).
102
+ # Declaring it also tells the rest of the library what fills this position: without
103
+ # it the branch resolves to `Any`, and a Codec has nothing to encode.
90
104
  policy :default, helper: true do |type, value = Undefined, &block|
91
105
  val_type = if value == Undefined
92
- Step.new(->(result) { result.valid(block.call) }, 'default proc')
106
+ fn = ->(result) { result.valid(block.call) }
107
+ Function.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
108
+ inspect: 'default proc', identity: [:default, block])
93
109
  else
94
110
  Types::Static[value]
95
111
  end
@@ -101,12 +117,20 @@ module Plumb
101
117
  # Expect a specific exception class, and return an invalid result if it is raised.
102
118
  # Usage:
103
119
  # type = Types::String.build(Date, :parse).policy(:rescue, Date::Error)
120
+ #
121
+ # The guard returns what `type` returned, so it declares `type`'s OUTPUT — opaque,
122
+ # it would erase the type it wraps (the closure is invisible) and resolve to `Any`.
123
+ # Unchecked: `type` already validated what it produced. The INPUT stays `Any`, or
124
+ # `type`'s own input check runs a second time, outside the rescue.
104
125
  policy :rescue do |type, exception_class|
105
- Step.new(nil, 'Rescue') do |result|
126
+ fn = lambda do |result|
106
127
  type.call(result)
107
128
  rescue exception_class => e
108
129
  result.invalid(errors: e.message)
109
130
  end
131
+
132
+ GuaranteedFunction.new(Types::Any, Plumb::Subtyping.resolved_output(type), fn,
133
+ inspect: 'Rescue', identity: [:rescue, type, exception_class])
110
134
  end
111
135
 
112
136
  # Split a string into an array. Default separator is /\s*,\s*/
@@ -133,11 +157,13 @@ module Plumb
133
157
  extend TypeRegistry
134
158
 
135
159
  Any = AnyClass.new
160
+ Never = NeverClass.new
136
161
  Undefined = Any.value(Plumb::Undefined)
137
162
  String = Any[::String]
138
163
  Symbol = Any[::Symbol]
139
164
  Numeric = Any[::Numeric]
140
165
  Integer = Any[::Integer]
166
+ Float = Any[::Float]
141
167
  Decimal = Any[BigDecimal]
142
168
  Static = StaticClass.new
143
169
  Value = ValueClass.new
@@ -149,11 +175,16 @@ module Plumb
149
175
  Stream = StreamClass.new
150
176
  Tuple = TupleClass.new
151
177
  Hash = HashClass.new
178
+ Range = RangeClass.new
152
179
  Not = Plumb::Not.new
153
180
  Interface = InterfaceClass.new
154
181
  Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
155
182
  Date = Any[::Date]
156
183
  Time = Any[::Time]
184
+ # What a lenient container accepts (a Stream, a `.filtered` Array/Hash/HashMap).
185
+ # Here, not beside those classes, because Types is loaded after all of them.
186
+ Each = Interface[:each]
187
+ EachPair = Interface[:each_pair]
157
188
 
158
189
  # A type that recursively converts string keys to symbols in nested hashes.
159
190
  # This is commonly used for normalizing payload data in commands and events.
@@ -166,7 +197,7 @@ module Plumb
166
197
  # SymbolizedHash.parse({ 'count' => 1, 'active' => true }) # => { count: 1, active: true }
167
198
  SymbolizedHash = Hash[
168
199
  # String keys are converted to symbols, existing symbols are preserved
169
- (Symbol | String.transform(::Symbol, &:to_sym)),
200
+ (Symbol | String.transform(::Symbol, :to_sym)),
170
201
  # Hash values are recursively symbolized, other types pass through unchanged
171
202
  Any.defer { SymbolizedHash } | Any
172
203
  ]
@@ -191,54 +222,24 @@ module Plumb
191
222
 
192
223
  String = Types::String \
193
224
  | Types::Decimal.transform(::String) { |v| v.to_s('F') } \
194
- | Types::Numeric.transform(::String, &:to_s)
225
+ | Types::Numeric.transform(::String, :to_s)
195
226
 
196
- Symbol = Types::Symbol | Types::String.transform(::Symbol, &:to_sym)
227
+ Symbol = Types::Symbol | Types::String.transform(::Symbol, :to_sym)
197
228
 
198
229
  NumberString = Types::String.match(NUMBER_EXPR)
199
230
  CoercibleNumberString = NumberString.transform(::String) { |v| v.tr(',', '') }
200
231
 
201
- Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric, &:to_f)
232
+ Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric, :to_f)
202
233
 
203
234
  Decimal = Types::Decimal | \
204
- (Types::Numeric.transform(::String, &:to_s) | CoercibleNumberString) \
235
+ (Types::Numeric.transform(::String, :to_s) | CoercibleNumberString) \
205
236
  .transform(::BigDecimal) { |v| BigDecimal(v) }
206
237
 
207
- Integer = Numeric.transform(::Integer, &:to_i)
238
+ Integer = Numeric.transform(::Integer, :to_i)
208
239
  end
209
240
 
210
- module Forms
211
- True = Types::True \
212
- | (
213
- Types::String[/^true$/i] \
214
- | Types::String['1'] \
215
- | Types::Integer[1]
216
- ).transform(::TrueClass) { |_| true }
217
-
218
- False = Types::False \
219
- | (
220
- Types::String[/^false$/i] \
221
- | Types::String['0'] \
222
- | Types::Integer[0]
223
- ).transform(::FalseClass) { |_| false }
224
-
225
- Boolean = True | False
226
-
227
- Nil = Nil | (String[BLANK_STRING] >> nil)
228
-
229
- # Accept a Date, or a string that can be parsed into a Date
230
- # via Date.parse
231
- Date = Date | (String >> Any.build(::Date, :parse).policy(:rescue, ::Date::Error))
232
- Time = Time | (String >> Any.build(::Time, :parse).policy(:rescue, ::ArgumentError))
233
-
234
- # Turn strings into different URI types
235
- module URI
236
- # URI.parse is very permisive - a blank string is valid.
237
- # We want to ensure that a generic URI at least starts with a scheme as per RFC 3986
238
- Generic = Types::URI::Generic | (String[/^([a-z][a-z0-9+\-.]*)/].build(::URI, :parse))
239
- HTTP = Generic[::URI::HTTP]
240
- File = Generic[::URI::File]
241
- end
242
- end
241
+ # NOTE: the one-way Types::Forms coercions were replaced by the two-way
242
+ # Plumb::Codec::Forms — apply it to a schema written in output types
243
+ # (`Codec::Forms >> schema`), or use its encoders per-field.
243
244
  end
244
245
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/composable'
4
+ require 'plumb/disjunction'
5
+
6
+ module Plumb
7
+ # THE LATTICE JOIN — the least upper bound of two types, dual to
8
+ # {Plumb::Intersection}. Built by `#|` when every branch returns its value
9
+ # untouched, so the node describes exactly "a value in either set" and is its own
10
+ # output type. That is the difference from {Plumb::Or}, whose ends differ.
11
+ class Union
12
+ include Composable
13
+ include Disjunction
14
+
15
+ # No lazy rebuild, no identity guard, no allocation — the mapping Or needs exists
16
+ # only because a converting branch makes its ends differ.
17
+ #
18
+ # NOT symmetric with #input_type, which is inherited from Disjunction: a branch may
19
+ # accept more than it describes, so the input side still has to map.
20
+ def output_type = self
21
+
22
+ # An invariant of the node: Disjunction.build only produces a Union when every
23
+ # branch preserves the value. @see Intersection#value_preserving?
24
+ def value_preserving? = true
25
+
26
+ # node_name comes from Naming (:union, derived from the class). The JSON
27
+ # Schema visitor handles it separately from :or — a union is a plain anyOf,
28
+ # with none of the default-value handling a choice needs.
29
+ end
30
+ end
@@ -3,6 +3,16 @@
3
3
  require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
+ # A single literal value, matched by `==`.
7
+ #
8
+ # Unlike the other bare matchers — a Constraint with no base, an
9
+ # AttributeValueMatch — this does NOT report `Types::Any` as its #input_type.
10
+ # Those opt out of #>>'s subtype check because they narrow arbitrary input and
11
+ # have no declared domain to check against; a literal's domain is a known
12
+ # singleton, so it keeps the default (`self`) and accepts only its own value.
13
+ # That is what makes `Value['a'] >> Value['b']` raise rather than build a chain
14
+ # no value can satisfy. Narrowing a wider type down to a literal is spelled
15
+ # `#[]` (`Types::String['a']`), which builds the refinement directly.
6
16
  class ValueClass
7
17
  include Composable
8
18
 
@@ -10,14 +20,23 @@ module Plumb
10
20
 
11
21
  def initialize(value = Undefined)
12
22
  @value = value
23
+ # Pre-build the failure message once (the object is frozen and @value is
24
+ # fixed), so a miss on the hot path — eg. every non-matching branch of a
25
+ # `Value[a] | Value[b]` enum union — doesn't allocate a fresh String.
26
+ # Frozen because it's shared across every failing call (and across threads
27
+ # under Types::Array#concurrent) and only ever read, never mutated.
28
+ @error = "Must be equal to #{value}".freeze
13
29
  @children = [value].freeze
14
30
  freeze
15
31
  end
16
32
 
17
33
  def [](value) = self.class.new(value)
18
34
 
35
+ # Matches a specific value and passes it through unchanged.
36
+ def value_preserving? = true
37
+
19
38
  def call(result)
20
- @value == result.value ? result : result.invalid(errors: "Must be equal to #{@value}")
39
+ @value == result.value ? result : result.invalid!(errors: @error)
21
40
  end
22
41
 
23
42
  private
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.0.18'
4
+ VERSION = '0.2.0.beta.2'
5
5
  end
@@ -2,6 +2,17 @@
2
2
 
3
3
  module Plumb
4
4
  module VisitorHandlers
5
+ # Node names introduced by the type/computation split, each mapped to the name
6
+ # the node was reported under before it existed. `Plumb::Intersection` used to
7
+ # be an `And` and `Plumb::Union` used to be an `Or`, so a visitor written
8
+ # against the old AST defines only `on(:and)` / `on(:or)` — without this it
9
+ # would raise "no handler" on a type it used to understand.
10
+ #
11
+ # Consulted ONLY when the specific handler is absent, so a visitor that does
12
+ # distinguish the two (see JSONSchemaVisitor) keeps full control. Plumb's own
13
+ # visitors register both names explicitly; this is for visitors outside the gem.
14
+ NODE_NAME_FALLBACKS = { intersection: :and, union: :or }.freeze
15
+
5
16
  def self.included(base)
6
17
  base.extend(ClassMethods)
7
18
  end
@@ -28,11 +39,16 @@ module Plumb
28
39
  end
29
40
 
30
41
  def visit_name(method_name, node, props = BLANK_HASH)
31
- method_name = :"visit_#{method_name}"
32
- if respond_to?(method_name)
33
- send(method_name, node, props)
42
+ target = :"visit_#{method_name}"
43
+
44
+ if !respond_to?(target) && (fallback = NODE_NAME_FALLBACKS[method_name])
45
+ target = :"visit_#{fallback}"
46
+ end
47
+
48
+ if respond_to?(target)
49
+ send(target, node, props)
34
50
  else
35
- on_missing_handler(node, props, method_name)
51
+ on_missing_handler(node, props, target)
36
52
  end
37
53
  end
38
54
 
data/lib/plumb.rb CHANGED
@@ -57,27 +57,114 @@ module Plumb
57
57
  def self.decorate(type, &block)
58
58
  Decorator.call(type, &block)
59
59
  end
60
+
61
+ # Recursively resolve the underlying Ruby classes ("base types") of a node.
62
+ # Types::String => [String]
63
+ # Types::String | Integer => [String, Integer]
64
+ #
65
+ # This is a temporary helper to preserve type-specific policy resolution
66
+ # (see Composable#policy) until proper subtyping checks are implemented.
67
+ # Returns the value's matching domain, widened to Numeric because numeric
68
+ # equality crosses concrete classes (`5 == 5.0`).
69
+ # Keeping a concrete numeric class would falsely make compatible literal and
70
+ # numeric types appear disjoint and reduce their intersection to Never.
71
+ # @param value [Object]
72
+ # @return [Class]
73
+ def self.value_base_class(value) = SemanticMatcher.value_domain(value)
74
+
75
+ def self.resolve_base_types(node)
76
+ return [node] if node.is_a?(::Class)
77
+ return [] unless node.respond_to?(:node_name)
78
+
79
+ # A transparent wrapper (Policy / Metadata / #as_node Node) only RE-LABELS the type
80
+ # it wraps, so it has the same base types. Peeled here rather than per branch: a
81
+ # Node's node_name is whatever #as_node was given, so it cannot be a `when` at all,
82
+ # and every #as_node type would report NO base types — which callers read as
83
+ # "unknown base, allow", silently skipping build-time checks like
84
+ # `Types::Email[1..20]`.
85
+ unwrapped = Plumb::Subtyping.unwrap_transparent(node)
86
+ return resolve_base_types(unwrapped) unless unwrapped.equal?(node)
87
+
88
+ case node.node_name
89
+ when :or, :union
90
+ node.children.flat_map { |child| resolve_base_types(child) }
91
+ when :function
92
+ resolve_base_types(node.output_type)
93
+ when :intersection
94
+ # A meet narrows a single value, so its base types are its LEFT's —
95
+ # `String.where(size: 1..3)` is still a String.
96
+ resolve_base_types(node.children[0])
97
+ when :and
98
+ # Descend into whichever side carries the resulting type: a value-preserving right
99
+ # NARROWS what the left produces, a converting right REPLACES it.
100
+ #
101
+ # Deliberately NOT `node.output_type`, which encodes the same rule but is not
102
+ # guaranteed to be a smaller node — for `Array[<record with a coercing field>]
103
+ # .where(size: 1..)` it is a DIFFERENT And whose own output type is itself, so
104
+ # following it ping-pongs until the stack blows. A child always terminates.
105
+ left, right = node.children
106
+ resolve_base_types(Plumb::Subtyping.value_preserving?(right) ? left : right)
107
+ when :constraint
108
+ # A refinement matcher carries its base type — resolve that (eg.
109
+ # `Integer[1..10]` => [Integer], `User.check {}` => the User's base types).
110
+ return resolve_base_types(node.base) if node.base
111
+
112
+ SemanticMatcher.matcher_domain(node.children.first)
113
+ when :array, :tuple then [::Array]
114
+ when :hash, :hash_map, :tagged_hash, :filtered_hash, :filtered_hash_map then [::Hash]
115
+ when :stream then [::Enumerator]
116
+ when :range then [::Range]
117
+ when :not
118
+ # A complement has no finite base-class representation.
119
+ []
120
+ when :value
121
+ # Literals match by ==, including across numeric classes.
122
+ value = node.children.first
123
+ value.equal?(Undefined) ? [] : [value_base_class(value)]
124
+ when :static
125
+ value = node.children.first
126
+ [value.is_a?(::Class) ? value : value_base_class(value)]
127
+ else
128
+ node.respond_to?(:children) ? node.children.flat_map { |child| resolve_base_types(child) } : []
129
+ end
130
+ end
60
131
  end
61
132
 
62
133
  require 'plumb/result'
63
134
  require 'plumb/type_registry'
135
+ require 'plumb/relation'
136
+ require 'plumb/semantic_matcher'
64
137
  require 'plumb/composable'
138
+ require 'plumb/typed_step'
139
+ require 'plumb/node_mapper'
140
+ require 'plumb/covariant_fusion'
65
141
  require 'plumb/any_class'
66
- require 'plumb/step'
142
+ require 'plumb/never_class'
143
+ require 'plumb/conjunction'
67
144
  require 'plumb/and'
145
+ require 'plumb/intersection'
146
+ require 'plumb/function'
147
+ require 'plumb/encoder'
148
+ require 'plumb/implementation'
68
149
  require 'plumb/pipeline'
69
150
  require 'plumb/static_class'
70
151
  require 'plumb/value_class'
71
- require 'plumb/match_class'
152
+ require 'plumb/constraint'
72
153
  require 'plumb/not'
154
+ require 'plumb/disjunction'
73
155
  require 'plumb/or'
156
+ require 'plumb/union'
74
157
  require 'plumb/tuple_class'
75
158
  require 'plumb/array_class'
76
159
  require 'plumb/stream_class'
77
160
  require 'plumb/hash_class'
161
+ require 'plumb/range_class'
78
162
  require 'plumb/interface_class'
79
163
  require 'plumb/attributes'
164
+ require 'plumb/subtyping'
165
+ require 'plumb/optimizer'
80
166
  require 'plumb/types'
167
+ require 'plumb/codec'
81
168
  require 'plumb/json_schema_visitor'
82
- require 'plumb/schema'
169
+ require 'plumb/mermaid_visitor'
83
170
  require 'plumb/decorator'