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
@@ -7,6 +7,11 @@ module Plumb
7
7
  class JSONSchemaVisitor
8
8
  include VisitorHandlers
9
9
 
10
+ # Raised when a value that has no JSON-native representation would end up in
11
+ # the generated schema. Register a handler (via `.on(...)`) that converts the
12
+ # offending type to a JSON-native value to resolve it.
13
+ InvalidJSONValueError = Class.new(StandardError)
14
+
10
15
  TYPE = 'type'
11
16
  PROPERTIES = 'properties'
12
17
  REQUIRED = 'required'
@@ -16,6 +21,8 @@ module Plumb
16
21
  NOT = 'not'
17
22
  ENUM = 'enum'
18
23
  CONST = 'const'
24
+ REF = '$ref'
25
+ DEFS = '$defs'
19
26
  ITEMS = 'items'
20
27
  PATTERN = 'pattern'
21
28
  MINIMUM = 'minimum'
@@ -25,15 +32,47 @@ module Plumb
25
32
  MIN_LENGTH = 'minLength'
26
33
  MAX_LENGTH = 'maxLength'
27
34
  FORMAT = 'format'
35
+ EXCLUSIVE_MAXIMUM = 'exclusiveMaximum'
36
+ FORMAT_MINIMUM = 'formatMinimum'
37
+ FORMAT_MAXIMUM = 'formatMaximum'
38
+ FORMAT_EXCLUSIVE_MAXIMUM = 'formatExclusiveMaximum'
28
39
  ENVELOPE = {
29
40
  '$schema' => 'https://json-schema.org/draft-08/schema#'
30
41
  }.freeze
31
42
 
32
43
  def self.call(node, root: true)
33
- data = new.visit(node)
34
- return data unless root
35
-
36
- ENVELOPE.merge(data)
44
+ visitor = new
45
+ data = visitor.visit(node)
46
+ # Deferred types register named entries in a `$defs` table and reference
47
+ # them by `$ref` (see the :deferred handler). Hoist that table to the top of
48
+ # the document so the root-relative `#/$defs/...` pointers resolve.
49
+ defs = visitor.defs
50
+ data = data.merge(DEFS => defs) unless defs.empty?
51
+ data = ENVELOPE.merge(data) if root
52
+ normalize_json(data)
53
+ end
54
+
55
+ # Recursively normalize the generated schema into JSON-native values and
56
+ # fail loudly on anything that has no JSON representation.
57
+ # Symbols are coerced to strings; everything that isn't a JSON-native value
58
+ # raises, so the user knows they need to register a custom visitor handler.
59
+ def self.normalize_json(value)
60
+ case value
61
+ when ::Hash
62
+ value.each_with_object({}) do |(key, val), hash|
63
+ hash[normalize_json(key)] = normalize_json(val)
64
+ end
65
+ when ::Array
66
+ value.map { |val| normalize_json(val) }
67
+ when ::Symbol
68
+ value.to_s
69
+ when ::String, ::Numeric, true, false, nil
70
+ value
71
+ else
72
+ raise InvalidJSONValueError,
73
+ "#{value.class} value #{value.inspect} cannot be serialized to JSON Schema. " \
74
+ 'Register a Plumb::JSONSchemaVisitor.on(...) handler that converts it to a JSON-native value.'
75
+ end
37
76
  end
38
77
 
39
78
  # Some rules that are dependent on combinations of aggregates keys
@@ -45,52 +84,177 @@ module Plumb
45
84
 
46
85
  private def stringify_keys(hash) = hash.transform_keys(&:to_s)
47
86
 
87
+ # The `$defs` table of materialized Deferred schemas built during this visit,
88
+ # hoisted to the document root by `.call`. Empty unless a Deferred was visited.
89
+ def defs = @defs || BLANK_HASH
90
+
91
+ # Register a Deferred under a stable `$defs` name and return that name. Keyed
92
+ # by the RESOLVED target type's identity (not the Deferred wrapper's), so
93
+ # distinct `defer { … }` wrappers pointing at the same type share one def.
94
+ # Resolving via #type is cheap (memoized) and does no visitor recursion, so we
95
+ # can key on it up front. The name is recorded BEFORE the body is visited, so a
96
+ # self-reference encountered while visiting finds the name already present and
97
+ # emits a `$ref` instead of recursing forever.
98
+ private def deferred_ref(node)
99
+ @defs ||= {}
100
+ @deferred_names ||= {}.compare_by_identity
101
+ target = node.type
102
+ existing = @deferred_names[target]
103
+ return existing if existing
104
+
105
+ name = "Deferred#{@deferred_names.size + 1}"
106
+ @deferred_names[target] = name
107
+ @defs[name] = visit(target)
108
+ name
109
+ end
110
+
48
111
  on(:any) do |_node, props|
49
112
  props
50
113
  end
51
114
 
52
- on(:pipeline) do |node, props|
53
- visit_children(node, props)
115
+ # The bottom type matches nothing — JSON Schema `{ "not": {} }` (the empty
116
+ # schema `{}` matches everything, so its negation matches nothing).
117
+ on(:never) do |_node, props|
118
+ props.merge('not' => {})
54
119
  end
55
120
 
56
- on(:step) do |node, props|
57
- props.merge(stringify_keys(node._metadata))
121
+ on(:pipeline) do |node, props|
122
+ visit_children(node, props)
58
123
  end
59
124
 
60
125
  on(:interface) do |_node, props|
61
126
  props
62
127
  end
63
128
 
64
- # Trying to visit the deferred could go into infinite recursion
65
- # if a type is deferring to itself
66
- # Not clear what deferred types would mean for JSON Schema anyway.
129
+ # A Deferred materializes to a concrete type (via #type), but that type may
130
+ # reference the Deferred back (a recursive/self-referential schema), so we
131
+ # can't inline it that would recurse forever. Instead each Deferred becomes a
132
+ # named `$defs` entry referenced by `$ref`, the standard JSON Schema idiom for
133
+ # recursion. `deferred_ref` builds the def (once) and returns its name; `.call`
134
+ # hoists the `$defs` table to the document root.
67
135
  on(:deferred) do |node, props|
68
- props
136
+ props.merge(REF => "#/#{DEFS}/#{deferred_ref(node)}")
69
137
  end
70
138
 
71
139
  on(:hash) do |node, props|
72
- props.merge(
140
+ # Named (literal) keys become properties/required. Typed/catch-all keys are
141
+ # not fixed property names: the `_` catch-all becomes `additionalProperties`
142
+ # (a schema; `{}` for Any = any value), and a pattern-backed typed key becomes
143
+ # a `patternProperties` entry (reusing the Regexp -> `pattern` handler).
144
+ literal = node.literal_fields
145
+ schema = {
73
146
  TYPE => 'object',
74
- PROPERTIES => node._schema.each_with_object({}) do |(key, value), hash|
147
+ PROPERTIES => literal.each_with_object({}) do |(key, value), hash|
75
148
  hash[key.to_s] = visit(value)
76
149
  end,
77
- REQUIRED => node._schema.reject { |key, _value| key.optional? }.keys.map(&:to_s)
78
- )
150
+ REQUIRED => literal.reject { |key, _value| key.optional? }.keys.map(&:to_s)
151
+ }
152
+
153
+ schema['additionalProperties'] = visit(node.catch_all_type) if node.catch_all_type
154
+
155
+ patterns = node.matcher_fields.each_with_object({}) do |(key, value), h|
156
+ next if key.catch_all?
157
+
158
+ pattern = visit(key.matcher)[PATTERN]
159
+ h[pattern] = visit(value) if pattern
160
+ end
161
+ schema['patternProperties'] = patterns unless patterns.empty?
162
+
163
+ props.merge(schema)
79
164
  end
80
165
 
81
166
  on(:data) do |node, props|
82
167
  visit_name :hash, node._schema, props
83
168
  end
84
169
 
170
+ # A refinement (`And`): both sides describe the SAME value. Build from the
171
+ # input side and fold in the output side when it shares the input's type (a
172
+ # constraint such as `pattern`/`minimum`/`format`), or when the input is
173
+ # untyped. Both sides are validators over one value, so both are visited.
174
+ # A factored union `And(base, Or(suffix, …))` (built by Composable#| when
175
+ # branches share a base type). Unlike the generic `:and` handler, the
176
+ # disjunction is a *refinement* of the base, not a different type: render the
177
+ # base, then fold the (type-less) disjunction's `anyOf` into that same spec.
178
+ on(:refined_union) do |node, props|
179
+ base, disjunction = node.type.children # the And's two sides, not its io types
180
+ props = visit(base, props) # base -> {type: …}
181
+ visit(disjunction, props) # Or(suffixes) -> merge anyOf into it
182
+ end
183
+
184
+ # A MEET (`Intersection`): both sides constrain the SAME value, so both specs
185
+ # describe it and both are merged — the left contributes the type, the right
186
+ # the narrowing keywords (`pattern`, `minimum`, `format`, …). No shape test is
187
+ # needed, which is the whole difference from the `:and` handler below: two
188
+ # sides that disagreed on `type` would describe an uninhabitable value, and
189
+ # `#&` folds that to Never before a schema is ever asked for.
190
+ on(:intersection) do |node, props|
191
+ left, right = node.children.map { |c| visit(c) }
192
+ merge_same_type_specs(props, left, right)
193
+ end
194
+
195
+ # A COMPOSITION (`And`): the right side may convert, so the two sides can
196
+ # describe genuinely different types. A JSON Schema describes accepted INPUTS,
197
+ # so build from the left and fold the right in only when it cannot contradict
198
+ # it — when the left is untyped (all we know comes from the right), or when
199
+ # both agree on `type` (the right is then narrowing the same type). When they
200
+ # disagree, the right is a conversion target and is dropped.
85
201
  on(:and) do |node, props|
86
202
  left, right = node.children.map { |c| visit(c) }
87
- type = right[TYPE] || left[TYPE]
88
- props = props.merge(left).merge(right)
89
- props = props.merge(TYPE => type) if type
90
- props
203
+ if !left.key?(TYPE) || left[TYPE] == right[TYPE]
204
+ merge_same_type_specs(props, left, right)
205
+ else
206
+ props.merge(left)
207
+ end
208
+ end
209
+
210
+ # Combine two specs that describe the SAME value: merge both sets of keywords
211
+ # and keep whichever `type` is present. Shared by the two handlers above, which
212
+ # differ only in WHEN they are allowed to do this.
213
+ private def merge_same_type_specs(props, left, right)
214
+ merged = props.merge(left).merge(right)
215
+ type = left[TYPE] || right[TYPE]
216
+ type ? merged.merge(TYPE => type) : merged
91
217
  end
92
218
 
93
- # A "default" value is usually an "or" of expected_value | (undefined >> static_value)
219
+ # A conversion (`Function`) produces a genuinely different value. A JSON
220
+ # Schema describes accepted *inputs*, so it is built from the input side and
221
+ # the output is dropped — and crucially NOT visited, so a transform whose
222
+ # output type has no schema handler (eg. a custom class via #build) is fine.
223
+ # Only when the input is untyped (eg. `Any.transform(::Integer)`) do we fall
224
+ # back to the output type, since that is then all we know.
225
+ # Encoder steps (see Plumb::Encoder) are plain Functions, so this covers
226
+ # them too: a decode-direction step's input IS the encoded form, which is
227
+ # why `Codec >> Type` describes the encoded side of a schema.
228
+ on(:function) do |node, props|
229
+ left = visit(node.input_type)
230
+ return props.merge(left) if left.key?(TYPE)
231
+
232
+ props.merge(left).merge(visit(node.output_type))
233
+ end
234
+
235
+ # A filtered Hash may drop any field, so its schema is its relaxed
236
+ # (all-optional) output.
237
+ on(:filtered_hash) do |node, props|
238
+ props.merge(visit(node.output_type))
239
+ end
240
+
241
+ # A JOIN (`Union`): every branch passes its value through, so this is a plain
242
+ # `anyOf` over the branch schemas. Nothing here has to look for a default: a
243
+ # `#default` is `(Undefined >> Static) | type`, whose first branch converts, so
244
+ # it is always a `:or` and never reaches this handler.
245
+ on(:union) do |node, props|
246
+ any_of = node.children.map { |c| visit(c) }.uniq.filter(&:any?)
247
+ return props.merge(any_of.first) if any_of.size == 1
248
+
249
+ props.merge(ANY_OF => splice_any_of(any_of))
250
+ end
251
+
252
+ # A CHOICE (`Or`): a branch may convert, which is what makes the two special
253
+ # shapes below possible.
254
+ #
255
+ # A "default" value is `expected_value | (undefined >> static_value)`: the
256
+ # static branch's spec is a DEFAULT rather than an alternative, so it is folded
257
+ # into the other branch instead of becoming an `anyOf` arm.
94
258
  on(:or) do |node, props|
95
259
  left, right = node.children.map { |c| visit(c) }
96
260
  any_of = [left, right].uniq.filter(&:any?)
@@ -100,10 +264,18 @@ module Plumb
100
264
  val = any_of[defidx.zero? ? 1 : 0]
101
265
  props.merge(val).merge(DEFAULT => any_of[defidx][DEFAULT])
102
266
  else
103
- props.merge(ANY_OF => any_of)
267
+ props.merge(ANY_OF => splice_any_of(any_of))
104
268
  end
105
269
  end
106
270
 
271
+ # anyOf is associative, so splice a child that is itself a BARE `anyOf` (a
272
+ # nested disjunction from an n-ary factored union) into this level to keep the
273
+ # schema flat. Only when it is pure anyOf — other keys would be lost, and a
274
+ # default-bearing branch is deliberately left un-spliced (see :or).
275
+ private def splice_any_of(any_of)
276
+ any_of.flat_map { |s| s.keys == [ANY_OF] ? s[ANY_OF] : [s] }.uniq
277
+ end
278
+
107
279
  on(:not) do |node, props|
108
280
  props.merge(NOT => visit_children(node))
109
281
  end
@@ -120,10 +292,6 @@ module Plumb
120
292
  visit(value, props)
121
293
  end
122
294
 
123
- on(:transform) do |node, props|
124
- visit_children(node, props)
125
- end
126
-
127
295
  on(:undefined) do |_node, props|
128
296
  props
129
297
  end
@@ -162,7 +330,10 @@ module Plumb
162
330
  end
163
331
 
164
332
  on(:options_policy) do |node, props|
165
- props.merge(ENUM => node.arg)
333
+ # Only an Array argument maps to `enum`. Any other argument was delegated
334
+ # to a Constraint by the policy, so its schema (eg. `minimum`/
335
+ # `maximum` for a Range) has already been built by visiting the children.
336
+ node.arg.is_a?(::Array) ? props.merge(ENUM => node.arg) : props
166
337
  end
167
338
 
168
339
  on(:with_size_attribute) do |node, props|
@@ -201,12 +372,19 @@ module Plumb
201
372
  props
202
373
  end
203
374
 
204
- on(:match) do |node, props|
375
+ on(:constraint) do |node, props|
376
+ # A refinement matcher describes its base first (eg. `Integer[1..10]` gets
377
+ # `type: integer` from the base Integer), then folds in the matcher's own
378
+ # constraint (`minimum`/`maximum`/`pattern`/`const`).
379
+ props = visit(node.base, props) if node.base
380
+
205
381
  # Set const if primitive
206
382
  matcher = node.children.first
207
383
  props = case matcher
208
384
  when ::String, ::Symbol, ::Numeric
209
385
  props.merge(CONST => matcher)
386
+ when ::Date, ::Time # also covers ::DateTime
387
+ props.merge(CONST => matcher.iso8601)
210
388
  else
211
389
  props
212
390
  end
@@ -262,15 +440,32 @@ module Plumb
262
440
  props.merge(PATTERN => node.source, TYPE => props[TYPE] || 'string')
263
441
  end
264
442
 
443
+ # A Set is a membership matcher (`Set#===` is `include?`), so it maps to
444
+ # `enum` — the same shape the :options_policy handler produces for an Array
445
+ # argument. `Integer[1, 2, 3]` / `Integer[Set[1, 2, 3]]` build a Constraint
446
+ # whose base sets `type` and whose Set matcher folds in the members here.
447
+ on(::Set) do |node, props|
448
+ props.merge(ENUM => node.to_a)
449
+ end
450
+
265
451
  on(::Range) do |node, props|
266
452
  element = node.begin || node.end
267
453
  opts = visit(element.class)
268
- if element.is_a?(::Numeric)
454
+ case element
455
+ when ::Numeric
269
456
  opts[MINIMUM] = node.min if node.begin
457
+ opts[MAXIMUM] = node.max if node.end
458
+ when ::Date, ::Time # also covers ::DateTime
459
+ # JSON Schema has no native min/max for date/time strings, so use the
460
+ # `formatMinimum`/`formatMaximum` keywords (the format-assertion
461
+ # convention also supported by ajv) alongside `format`.
462
+ opts[FORMAT_MINIMUM] = node.begin.iso8601 if node.begin
270
463
  if node.end
271
- max = node.end
272
- max -= 1 if node.exclude_end?
273
- opts[MAXIMUM] = max
464
+ if node.exclude_end?
465
+ opts[FORMAT_EXCLUSIVE_MAXIMUM] = node.end.iso8601
466
+ else
467
+ opts[FORMAT_MAXIMUM] = node.end.iso8601
468
+ end
274
469
  end
275
470
  end
276
471
  props.merge(opts)
@@ -328,10 +523,6 @@ module Plumb
328
523
  }
329
524
  end
330
525
 
331
- on(:build) do |node, props|
332
- visit_children(node, props)
333
- end
334
-
335
526
  on(:array) do |node, _props|
336
527
  items_props = visit_children(node)
337
528
  { TYPE => 'array', ITEMS => items_props }
@@ -347,6 +538,33 @@ module Plumb
347
538
  { TYPE => 'array', 'prefixItems' => items }
348
539
  end
349
540
 
541
+ # A Types::Range whose member type pins the bounds (`Types::Range[0...100]`)
542
+ # maps to the member's scalar schema plus JSON Schema's native bound
543
+ # keywords. Unlike the range-*matcher* handler below (`on(::Range)`), this
544
+ # preserves an exclusive end as `exclusiveMaximum` rather than approximating
545
+ # it as `maximum - 1` (which also can't be computed for float ranges).
546
+ # A member with no bounds (`Types::Range[Integer]`) falls back to its own
547
+ # schema.
548
+ on(:range) do |node, _props|
549
+ member = node.children.first
550
+ matcher = member.respond_to?(:children) ? member.children.first : nil
551
+ next visit(member) unless matcher.is_a?(::Range)
552
+
553
+ element = matcher.begin || matcher.end
554
+ opts = visit(element.class)
555
+ case element
556
+ when ::Numeric
557
+ opts[MINIMUM] = matcher.begin if matcher.begin
558
+ opts[matcher.exclude_end? ? EXCLUSIVE_MAXIMUM : MAXIMUM] = matcher.end if matcher.end
559
+ when ::Date, ::Time # also covers ::DateTime
560
+ opts[FORMAT_MINIMUM] = matcher.begin.iso8601 if matcher.begin
561
+ if matcher.end
562
+ opts[matcher.exclude_end? ? FORMAT_EXCLUSIVE_MAXIMUM : FORMAT_MAXIMUM] = matcher.end.iso8601
563
+ end
564
+ end
565
+ opts
566
+ end
567
+
350
568
  on(:tagged_hash) do |node, _props|
351
569
  required = Set.new
352
570
  result = {
@@ -373,5 +591,6 @@ module Plumb
373
591
 
374
592
  result.merge(REQUIRED => required.to_a)
375
593
  end
594
+
376
595
  end
377
596
  end
data/lib/plumb/key.rb CHANGED
@@ -1,6 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'plumb/composable'
4
+
3
5
  module Plumb
6
+ # A hash-schema key. Two flavours:
7
+ #
8
+ # * a LITERAL key — a Symbol or String name (`name:`, `'name' =>`). It is
9
+ # matched by exact lookup and carries `#to_key`/`#to_sym`. A trailing `?`
10
+ # (`name?:`) marks it optional. This is the common case and behaves exactly
11
+ # as before.
12
+ # * a MATCHER key — any Plumb type / `#===` object used as a key
13
+ # (`Types::String[/^id_/] => ...`, or the `_` catch-all which wraps
14
+ # `Types::Any`). It matches other keys via `matcher === other_key`, has no
15
+ # single `#to_key`, and is inherently optional (it imposes no specific
16
+ # required key).
4
17
  class Key
5
18
  # OPTIONAL_EXP = /(\w+)(\?)?$/
6
19
  OPTIONAL_EXP = /(?<word>[A-Za-z0-9_$]+)(?<qmark>\?)?/
@@ -9,27 +22,58 @@ module Plumb
9
22
  key.is_a?(Key) ? key : new(key, symbolize:)
10
23
  end
11
24
 
12
- attr_reader :to_key, :to_sym, :node_name
25
+ attr_reader :to_key, :to_sym, :node_name, :matcher
13
26
 
14
27
  def initialize(key, optional: false, symbolize: false)
15
- key_type = symbolize ? Symbol : key.class
16
- match = OPTIONAL_EXP.match(key.to_s)
17
- key = match[:word]
18
- @to_key = key_type == Symbol ? key.to_sym : key
19
- @to_sym = @to_key.to_sym
20
- @optional = !match[:qmark].nil? ? true : optional
21
28
  @node_name = :key
29
+ if key.is_a?(::Symbol) || key.is_a?(::String)
30
+ key_type = symbolize ? Symbol : key.class
31
+ match = OPTIONAL_EXP.match(key.to_s)
32
+ name = match[:word]
33
+ @to_key = key_type == Symbol ? name.to_sym : name
34
+ @to_sym = @to_key.to_sym
35
+ @optional = !match[:qmark].nil? ? true : optional
36
+ @matcher = @to_key
37
+ @literal = true
38
+ else
39
+ # A type/matcher key. It matches other keys structurally, so it has no
40
+ # concrete #to_key and never imposes a required key.
41
+ @matcher = Composable.wrap(key)
42
+ @to_key = nil
43
+ @to_sym = nil
44
+ @optional = true
45
+ @literal = false
46
+ end
22
47
  freeze
23
48
  end
24
49
 
25
- def to_s = @to_key.to_s
50
+ # A concrete Symbol/String key (exact lookup) vs a type/matcher key.
51
+ def literal? = @literal
26
52
 
27
- def hash
28
- @to_key.hash
29
- end
53
+ # The `_` catch-all: a matcher key over the Any top, so it matches every key.
54
+ def catch_all? = !@literal && @matcher.is_a?(AnyClass)
55
+
56
+ # Does this key match `other` (a raw hash key)? Literal keys match by `==`
57
+ # (`Symbol#===`/`String#===`); matcher keys by `matcher === other`.
58
+ def match?(other) = @matcher === other
59
+
60
+ def to_s = (@to_key || @matcher).to_s
61
+
62
+ # Dedupe/lookup identity. Literal keys hash by name (unchanged); matcher keys
63
+ # hash by matcher class (structural equality disambiguates via #eql?), so two
64
+ # `_` catch-alls collapse. Two keys are eql? when both literal with the same
65
+ # name, or both matchers with equal matchers — optionality is deliberately
66
+ # ignored (so `name?`/`name` collapse), and HashClass#== compares it separately.
67
+ def hash = @literal ? @to_key.hash : @matcher.class.hash
30
68
 
31
69
  def eql?(other)
32
- other.hash == hash
70
+ return false unless other.is_a?(Key)
71
+
72
+ if @literal
73
+ other.literal? && @to_key == other.to_key
74
+ else
75
+ !other.literal? && @matcher == other.matcher
76
+ end
33
77
  end
34
78
 
35
79
  def optional?
@@ -37,7 +81,13 @@ module Plumb
37
81
  end
38
82
 
39
83
  def inspect
40
- "#{@to_key}#{'?' if @optional}"
84
+ if @literal
85
+ "#{@to_key}#{'?' if @optional}"
86
+ elsif catch_all?
87
+ '_'
88
+ else
89
+ @matcher.inspect
90
+ end
41
91
  end
42
92
  end
43
93
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/visitor_handlers'
4
+
5
+ module Plumb
6
+ # Renders a type composition as a Mermaid `flowchart`, so the flow-control
7
+ # algebra can be visualised. `>>` (And) becomes sequential arrows; `|` (Or)
8
+ # becomes a fork — the predecessor fans out to each alternative.
9
+ #
10
+ # Every `visit` returns `{ entries:, exits: }` — the node ids an incoming
11
+ # arrow should point INTO, and the ids outgoing arrows leave FROM — and
12
+ # accumulates node/edge lines as a side effect (the same stateful pattern
13
+ # JSONSchemaVisitor uses for `$defs`). And connects `left.exits -> right.entries`
14
+ # (a join over a preceding Or's branches); Or unions both branches' entries and
15
+ # exits without a node of its own, so whatever precedes it forks to both.
16
+ #
17
+ # @example
18
+ # ((A >> B) | (C >> (D | B))).to_mermaid
19
+ class MermaidVisitor
20
+ include VisitorHandlers
21
+
22
+ def self.call(node, direction: 'LR')
23
+ new.render(node, direction:)
24
+ end
25
+
26
+ def initialize
27
+ @nodes = []
28
+ @edges = []
29
+ @counter = 0
30
+ end
31
+
32
+ def render(node, direction: 'LR')
33
+ root = visit(node)
34
+ # A top-level Or has more than one entry; anchor them to a synthetic start
35
+ # node so the diagram reads as a single connected fork.
36
+ entries = root[:entries]
37
+ if entries.size > 1
38
+ @nodes.unshift('start(( ))')
39
+ @edges = entries.map { |e| "start --> #{e}" } + @edges
40
+ end
41
+
42
+ lines = ["flowchart #{direction}"]
43
+ (@nodes + @edges).each { |line| lines << " #{line}" }
44
+ lines.join("\n")
45
+ end
46
+
47
+ # Unknown nodes render as a single labeled box rather than raising, so any
48
+ # leaf type (Step, Transform, Constraint, Hash, Array, Boolean, …) degrades
49
+ # gracefully — mirrors MetadataVisitor's recursing override.
50
+ def on_missing_handler(node, _props, _method_name)
51
+ box(node)
52
+ end
53
+
54
+ # `>>` — sequence: connect the left's exits to the right's entries.
55
+ on(:and) do |node, _props|
56
+ left = visit(node.children[0])
57
+ right = visit(node.children[1])
58
+ connect(left[:exits], right[:entries])
59
+ { entries: left[:entries], exits: right[:exits] }
60
+ end
61
+
62
+ # `|` — fork: no node of its own; union both branches so a preceding step
63
+ # forks to both entries and a following step joins both exits.
64
+ on(:or) do |node, _props|
65
+ left = visit(node.children[0])
66
+ right = visit(node.children[1])
67
+ { entries: left[:entries] + right[:entries], exits: left[:exits] + right[:exits] }
68
+ end
69
+
70
+ # Transparent wrappers — recurse into the single inner composition.
71
+ on(:pipeline) do |node, _props|
72
+ visit(node.children[0])
73
+ end
74
+
75
+ on(:policy) do |node, _props|
76
+ visit(node.children[0])
77
+ end
78
+
79
+ # A Metadata node is a box; its label comes from its own metadata (see #label_for).
80
+ on(:metadata) do |node, _props|
81
+ box(node)
82
+ end
83
+
84
+ # A Deferred is (possibly) recursive, so render it as an opaque box and do
85
+ # NOT recurse — mirrors why JSONSchemaVisitor special-cases Deferred.
86
+ on(:deferred) do |node, _props|
87
+ box(node)
88
+ end
89
+
90
+ private
91
+
92
+ def box(node)
93
+ id = next_id
94
+ @nodes << %(#{id}["#{escape(label_for(node))}"])
95
+ { entries: [id], exits: [id] }
96
+ end
97
+
98
+ def connect(from_ids, to_ids)
99
+ from_ids.each do |from|
100
+ to_ids.each { |to| @edges << "#{from} --> #{to}" }
101
+ end
102
+ end
103
+
104
+ def next_id
105
+ @counter += 1
106
+ "n#{@counter}"
107
+ end
108
+
109
+ # Prefer a node's OWN metadata title/label, else fall back to #inspect. Only
110
+ # nodes that carry metadata directly are consulted — calling #metadata on an
111
+ # And/Or would merge the whole subtree's metadata (wrong for a single box).
112
+ def label_for(node)
113
+ md = own_metadata(node)
114
+ md[:title] || md[:label] || node.inspect
115
+ end
116
+
117
+ def own_metadata(node)
118
+ case node.node_name
119
+ when :metadata then node.metadata
120
+ when :step then node._metadata
121
+ else BLANK_HASH
122
+ end
123
+ rescue StandardError
124
+ BLANK_HASH
125
+ end
126
+
127
+ def escape(label) = label.to_s.gsub('"', '#quot;')
128
+ end
129
+ end
@@ -12,8 +12,12 @@ module Plumb
12
12
  freeze
13
13
  end
14
14
 
15
+ # Metadata nodes include their wrapped type in equality; otherwise unrelated
16
+ # types carrying the same metadata could collapse during subtype reductions.
17
+ # @param other [Object]
18
+ # @return [Boolean]
15
19
  def ==(other)
16
- other.is_a?(self.class) && @metadata == other.metadata
20
+ other.instance_of?(self.class) && type == other.type && @metadata == other.metadata
17
21
  end
18
22
 
19
23
  def metadata(data = Undefined)
@@ -24,6 +28,11 @@ module Plumb
24
28
  end
25
29
  end
26
30
 
31
+ # Metadata is a transparent wrapper: it delegates type-flow to the wrapped
32
+ # type.
33
+ def input_type = type.input_type
34
+ def output_type = type.output_type
35
+
27
36
  def call(result) = type.call(result)
28
37
 
29
38
  private def _inspect = "Metadata[#{type}, #{@metadata.inspect}]"