Module: Plumbing::Operation::Generator

Defined in:
lib/plumbing/operation/generator.rb

Overview

Generates a Plumbing::Operation skeleton from a mermaid flowchart — the inverse of Task#to_mermaid. Pure text-in / text-out; no runtime deps.

Defined Under Namespace

Classes: Edge, Node

Constant Summary collapse

Error =
Class.new(StandardError)
NODE_AND_EDGE =

A node (id + shape) optionally followed by an edge on the same line.

/\A
  (?<node>\w+(?:\(\[.*?\]\)|\{\{".*?"\}\}|\{".*?"\}|\[".*?"\]))
  (?:\s*-->\s*(?:\|(?<elabel>[^|]*)\|\s*)?(?<target>\w+))?
\z/x
EDGE_ONLY =

A standalone edge: from --> to or from -->|label| to.

/\A(?<from>\w+)\s*-->\s*(?:\|(?<elabel>[^|]*)\|\s*)?(?<target>\w+)\z/

Class Method Summary collapse

Class Method Details

.blank_to_nil(str) ⇒ Object



75
# File 'lib/plumbing/operation/generator.rb', line 75

def blank_to_nil(str) = (str.nil? || str.empty?) ? nil : str

.build_node(token, lineno) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/plumbing/operation/generator.rb', line 53

def build_node(token, lineno)
  case token
  when /\A(\w+)\(\[(.*)\]\)\z/
    id = $1
    label = unquote($2)
    (id == "start" || label.casecmp?("Start")) ? [Node.new(id, :start, label), :start] : [Node.new(id, :result, label), :result]
  when /\A(\w+)\{\{(.*)\}\}\z/
    [Node.new($1, :wait, unquote($2)), :wait]
  when /\A(\w+)\{(.*)\}\z/
    [Node.new($1, :decision, unquote($2)), :decision]
  when /\A(\w+)\[(.*)\]\z/
    [Node.new($1, :action, unquote($2)), :action]
  else
    raise Error, "line #{lineno}: unrecognised node #{token.inspect}"
  end
end

.emit(nodes, edges, start, class_name) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/plumbing/operation/generator.rb', line 109

def emit(nodes, edges, start, class_name)
  out = []
  out << "# Generated from a mermaid flowchart. Fill in the attributes, guard bodies and"
  out << "# action bodies (marked `raise NotImplementedError`), then run `standardrb --fix`."
  out << "class #{class_name} < Plumbing::Operation"
  out << "  # TODO: declare attributes, e.g. attribute :name, String"
  out << ""
  out << "  starts_with :#{start}"
  nodes.each_value do |node|
    out << ""
    out.concat(emit_node(node, edges))
  end
  out << "end"
  out.join("\n") + "\n"
end

.emit_node(node, edges) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/plumbing/operation/generator.rb', line 125

def emit_node(node, edges)
  outgoing = edges.select { |edge| edge.from == node.id }
  lines = []
  lines << "  # #{node.label}" if node.label && !node.label.empty? && node.label != node.id
  case node.kind
  when :action
    edge = outgoing.first
    lines << "  # (edge label #{edge.label.inspect} dropped — actions take no label)" if edge.label
    lines << "  action(:#{node.id}) { raise NotImplementedError }.then :#{edge.to}"
  when :decision
    lines << "  decision :#{node.id} do"
    outgoing.each { |edge| lines << "    #{go_to(edge)}" }
    lines << "  end"
  when :wait
    lines << "  wait_until :#{node.id} do"
    outgoing.each { |edge| lines << "    #{go_to(edge)}" }
    lines << "  end"
  when :result
    lines << "  result :#{node.id}"
  end
  lines
end

.from_mermaid(mermaid, class_name:) ⇒ Object



77
78
79
80
81
# File 'lib/plumbing/operation/generator.rb', line 77

def from_mermaid(mermaid, class_name:)
  nodes, edges, start_target = parse(mermaid)
  validate!(nodes, edges)
  emit(nodes, edges, resolve_start(nodes, edges, start_target), class_name)
end

.go_to(edge) ⇒ Object



148
149
150
151
152
153
154
# File 'lib/plumbing/operation/generator.rb', line 148

def go_to(edge)
  if edge.label
    "go_to :#{edge.to}, #{edge.label.inspect}, if: -> { raise NotImplementedError }"
  else
    "go_to :#{edge.to}"
  end
end

.parse(text) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/plumbing/operation/generator.rb', line 24

def parse(text)
  nodes = {}
  edges = []
  start_target = nil
  text.each_line.with_index(1) do |raw, lineno|
    line = raw.strip
    next if line.empty? || line.start_with?("%%", "flowchart", "graph")

    if (m = NODE_AND_EDGE.match(line))
      node, kind = build_node(m[:node], lineno)
      if kind == :start
        start_target = m[:target] if m[:target]
      else
        nodes[node.id] = node
        edges << Edge.new(node.id, m[:target], blank_to_nil(m[:elabel])) if m[:target]
      end
    elsif (m = EDGE_ONLY.match(line))
      if m[:from] == "start"
        start_target = m[:target]
      else
        edges << Edge.new(m[:from], m[:target], blank_to_nil(m[:elabel]))
      end
    else
      raise Error, "line #{lineno}: cannot parse #{line.inspect}"
    end
  end
  [nodes, edges, start_target]
end

.resolve_start(nodes, edges, start_target) ⇒ Object

Raises:



101
102
103
104
105
106
107
# File 'lib/plumbing/operation/generator.rb', line 101

def resolve_start(nodes, edges, start_target)
  return start_target if start_target
  inbound = edges.map(&:to)
  candidates = nodes.keys.reject { |id| inbound.include?(id) }
  raise Error, "cannot determine the start state (no Start marker; #{candidates.size} nodes have no inbound edge)" unless candidates.size == 1
  candidates.first
end

.unquote(str) ⇒ Object



70
71
72
73
# File 'lib/plumbing/operation/generator.rb', line 70

def unquote(str)
  s = str.strip
  (s.start_with?('"') && s.end_with?('"') && s.length >= 2) ? s[1..-2] : s
end

.validate!(nodes, edges) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/plumbing/operation/generator.rb', line 83

def validate!(nodes, edges)
  edges.each do |edge|
    raise Error, "edge references undefined node :#{edge.from}" unless nodes.key?(edge.from)
    raise Error, "edge references undefined node :#{edge.to}" unless nodes.key?(edge.to)
  end
  nodes.each_value do |node|
    outgoing = edges.count { |edge| edge.from == node.id }
    case node.kind
    when :action
      raise Error, "action :#{node.id} has #{outgoing} transitions — actions take exactly one (use a decision/wait shape)" unless outgoing == 1
    when :decision, :wait
      raise Error, "#{node.kind} :#{node.id} has no outgoing transitions" if outgoing.zero?
    when :result
      raise Error, "result :#{node.id} has outgoing transitions" unless outgoing.zero?
    end
  end
end