Module: Plumb::NodeMapper

Defined in:
lib/plumb/node_mapper.rb

Overview

THE STRUCTURAL REWRITE PRIMITIVE: map a node's Composable sub-types through a block and rebuild the node around the results — or return the ORIGINAL node when no sub-type changed.

The identity guard is why this belongs in one place: every rewriting pass depends on an untouched subtree coming back equal? — so a caller can tell "nothing happened" from "rebuilt identically", and a no-op pass allocates nothing — and it must hold uniformly across a dozen node shapes. Decorator is the cautionary tale: it grew its own case over five node types and silently recursed into no container or wrapper, so a block never saw a schema's fields.

Each node contributes only its own rebuild, via the one-line #with_children hook where its #children already ARE its sub-types. Nodes whose sub-types live elsewhere — a Metadata's #type, a record's keyed fields — are handled below.

NOT EXHAUSTIVE, deliberately for now: map dispatches on a closed class list, so a composite it does not name (Plumb::Implementation, or a user-defined one) is returned untouched. Generalizing means a #map_subtypes(&blk) hook on each node — the four branches below are the cases a plain #with_children(array) cannot express. Worth doing when a third pass needs it.

DELIBERATELY NOT MAPPED, both matching what every existing pass already does:

- Constraint's #base. A Constraint carries an error message and a label that
it does not expose, so rebuilding one would silently drop a custom
`#check('must start with a')` message.
- Deferred. Forcing it to recurse would loop forever on a self-referential
type. A pass that wants to rewrite through one must tie the knot itself
with its own memo, as Codec::Rewriter does.

Examples:

NodeMapper.map(type) { |child| rewrite(child) }

Class Method Summary collapse

Class Method Details

.map(type) {|child| ... } ⇒ Composable

Returns type itself when nothing changed, else a rebuilt node.

Parameters:

Yield Parameters:

  • child (Composable)

    a Composable sub-type of type

Returns:

  • (Composable)

    type itself when nothing changed, else a rebuilt node



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/plumb/node_mapper.rb', line 42

def map(type, &blk)
  # Leaf fast path: a Constraint is the most numerous node in any real graph and
  # is deliberately not mapped (see above), so answer it before the class tests.
  return type if type.is_a?(Constraint) || !type.respond_to?(:children)

  case type
  # Nodes whose #children ARE their sub-types: each contributes its own
  # #with_children rebuild and this owns the traversal + identity guard.
  when Conjunction, Disjunction, Function, TupleClass, ArrayClass, HashMap, StreamClass,
       Not, Policy, TaggedHash
    map_children(type, &blk)
  # Transparent wrappers: the sub-type is #type, not a child.
  when Metadata
    rewrap(type, type.type, blk) { |t| Metadata.new(t, type.) }
  when Composable::Node
    rewrap(type, type.type, blk) { |t| t.as_node(type.node_name, type.args) }
  # A record's sub-types are its keyed fields.
  when HashClass
    map_record(type, &blk)
  else
    type
  end
end

.map_children(type, &blk) ⇒ Object

Map type's #children and rebuild via #with_children, preserving identity when every child came back the same.



68
69
70
71
72
73
74
75
76
# File 'lib/plumb/node_mapper.rb', line 68

def map_children(type, &blk)
  children = type.children
  return type if children.empty?

  mapped = children.map(&blk)
  return type if mapped.each_with_index.all? { |m, i| m.equal?(children[i]) }

  type.with_children(mapped)
end

.map_record(type, &blk) ⇒ Object

Yields (field, key) — the key so a caller can label the position (see Codec::Rewriter#visit_hash, which builds an error path from it).



80
81
82
83
84
85
86
87
88
# File 'lib/plumb/node_mapper.rb', line 80

def map_record(type, &blk)
  changed = false
  schema = type._schema.each_with_object({}) do |(key, field), acc|
    mapped = blk.call(field, key)
    changed ||= !mapped.equal?(field)
    acc[key] = mapped
  end
  changed ? type.class.new(schema:) : type
end

.rewrap(original, inner, blk) ⇒ Object

A transparent wrapper: map the wrapped type, keep the wrapper when it did not change, else re-wrap via the block.



92
93
94
95
# File 'lib/plumb/node_mapper.rb', line 92

def rewrap(original, inner, blk)
  mapped = blk.call(inner)
  mapped.equal?(inner) ? original : yield(mapped)
end