merminal 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +85 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-runtime.md +19 -0
- data/docs/adr/002-scene.md +18 -0
- data/docs/adr/003-layout.md +20 -0
- data/docs/adr/004-parser.md +19 -0
- data/docs/adr/005-public-api.md +23 -0
- data/docs/adr/README.md +10 -0
- data/docs/syntax/additional.md +58 -0
- data/docs/syntax/class.md +3 -0
- data/docs/syntax/er.md +3 -0
- data/docs/syntax/flowchart.md +16 -0
- data/docs/syntax/gantt.md +3 -0
- data/docs/syntax/mindmap.md +3 -0
- data/docs/syntax/pie.md +3 -0
- data/docs/syntax/sequence.md +14 -0
- data/docs/syntax/state.md +3 -0
- data/docs/syntax/timeline.md +3 -0
- data/docs/syntax/xychart.md +3 -0
- data/exe/merminal +7 -0
- data/lib/merminal/cli.rb +94 -0
- data/lib/merminal/diagrams/additional.rb +257 -0
- data/lib/merminal/diagrams/additional_layouts.rb +1274 -0
- data/lib/merminal/diagrams/base.rb +65 -0
- data/lib/merminal/diagrams/gantt.rb +109 -0
- data/lib/merminal/diagrams/mindmap.rb +94 -0
- data/lib/merminal/diagrams/pie.rb +64 -0
- data/lib/merminal/diagrams/sequence.rb +186 -0
- data/lib/merminal/diagrams/structure.rb +316 -0
- data/lib/merminal/diagrams/timeline.rb +60 -0
- data/lib/merminal/diagrams/xychart.rb +152 -0
- data/lib/merminal/flowchart/layout.rb +734 -0
- data/lib/merminal/flowchart.rb +279 -0
- data/lib/merminal/markdown.rb +37 -0
- data/lib/merminal/output.rb +93 -0
- data/lib/merminal/raster/box_drawing_table.rb +6 -0
- data/lib/merminal/raster.rb +175 -0
- data/lib/merminal/scene.rb +37 -0
- data/lib/merminal/shareable.rb +9 -0
- data/lib/merminal/source.rb +78 -0
- data/lib/merminal/text/east_asian_width_table.rb +8 -0
- data/lib/merminal/text.rb +110 -0
- data/lib/merminal/version.rb +5 -0
- data/lib/merminal.rb +122 -0
- data/sig/merminal.rbs +59 -0
- metadata +88 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Native layouts cover each registered syntax; the shared scene is retained
|
|
5
|
+
# only as a compatibility path for incomplete input.
|
|
6
|
+
module Additional
|
|
7
|
+
Diagram = Data.define(:keyword, :title, :lines, :nodes, :edges, :direction)
|
|
8
|
+
Node = Data.define(:id, :label)
|
|
9
|
+
Edge = Data.define(:from, :to, :label)
|
|
10
|
+
DIRECTIONS = Merminal::Flowchart::DIRECTIONS
|
|
11
|
+
EDGE_PATTERN = /(?:<\||<|o|x)?(?:-{2,}|<-|->|\.{2,}|={2,}|~{3,})(?:\|>|>|o|x)?/
|
|
12
|
+
C4_NODE_TYPES = %w[
|
|
13
|
+
Person Person_Ext System System_Ext SystemDb SystemDb_Ext SystemQueue SystemQueue_Ext
|
|
14
|
+
Boundary Enterprise_Boundary System_Boundary Container Container_Ext ContainerDb ContainerDb_Ext
|
|
15
|
+
ContainerQueue ContainerQueue_Ext Container_Boundary Component Component_Ext ComponentDb ComponentDb_Ext
|
|
16
|
+
ComponentQueue ComponentQueue_Ext Deployment_Node Node Node_L Node_R
|
|
17
|
+
].freeze
|
|
18
|
+
C4_RELATION = /\A(?:Rel(?:_Back|_Up|_Down|_Left|_Right|_U|_D|_L|_R)?|BiRel)\(([^,]+),\s*([^,]+)(?:,\s*["']?([^"')]+)["']?)?/
|
|
19
|
+
|
|
20
|
+
KEYWORDS = %w[
|
|
21
|
+
journey quadrantChart requirementDiagram usecaseDiagram usecase-beta gitGraph
|
|
22
|
+
C4Context C4Container C4Component C4Dynamic C4Deployment zenuml
|
|
23
|
+
sankey-beta block-beta packet-beta kanban architecture-beta radar-beta
|
|
24
|
+
treemap-beta venn-beta ishikawa-beta wardley-beta treeView treeView-beta
|
|
25
|
+
cynefin-beta swimlane-beta eventModeling agentflow-beta
|
|
26
|
+
railroad-ebnf-beta railroad-abnf-beta railroad-peg-beta railroad-beta
|
|
27
|
+
].freeze
|
|
28
|
+
ALIASES = {
|
|
29
|
+
"gitGraph" => %w[gitGraph gitgraph],
|
|
30
|
+
"block-beta" => %w[block-beta block],
|
|
31
|
+
"packet-beta" => %w[packet-beta packet],
|
|
32
|
+
"treeView" => %w[treeView treeview],
|
|
33
|
+
"treeView-beta" => %w[treeView-beta treeview-beta],
|
|
34
|
+
"eventModeling" => %w[eventModeling eventmodeling],
|
|
35
|
+
"agentflow-beta" => %w[agentflow-beta agentflow]
|
|
36
|
+
}.transform_values(&:freeze).freeze
|
|
37
|
+
|
|
38
|
+
DECLARATION = /\A(?:participant|actor|class|state|service|interface|component|container|system|person|database|requirement|functionalRequirement|interfaceRequirement|performanceRequirement|physicalRequirement|designConstraint|element|usecase|systemBoundary|json|group|rect|boundary|package|namespace|entity|node|junction)\s+([^\s\[\](){}:]+)(?:\s+as\s+(.+)|\[([^\]]+)\]|\(([^)]+)\))?/i
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def plugin(keyword, aliases: [keyword])
|
|
43
|
+
Module.new do
|
|
44
|
+
define_singleton_method(:diagram_type) { keyword.to_s.gsub(/[^a-zA-Z0-9]+/, "_").downcase.to_sym }
|
|
45
|
+
define_singleton_method(:keywords) { aliases }
|
|
46
|
+
define_singleton_method(:parse) { |source| Additional.parse(source, keyword) }
|
|
47
|
+
define_singleton_method(:layout) { |ast, **options| Additional.layout(ast, **options) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse(source, keyword)
|
|
52
|
+
lines = source.lines.drop(1).map(&:to_s).map(&:rstrip).reject { |line| line.strip.empty? }
|
|
53
|
+
title = source.title
|
|
54
|
+
direction = source.lines.first.to_s.split[1].to_s.delete_suffix(":").then do |value|
|
|
55
|
+
next value.to_sym if DIRECTIONS.include?(value)
|
|
56
|
+
|
|
57
|
+
keyword == "gitGraph" ? :LR : :TB
|
|
58
|
+
end
|
|
59
|
+
nodes = {}
|
|
60
|
+
edges = []
|
|
61
|
+
|
|
62
|
+
lines.each do |raw_line|
|
|
63
|
+
line = raw_line.strip
|
|
64
|
+
next if keyword == "quadrantChart" && line.match?(/\A[xy]-axis\b/i)
|
|
65
|
+
|
|
66
|
+
case line
|
|
67
|
+
when /\Atitle\s+(.+)\z/i
|
|
68
|
+
title ||= unquote(Regexp.last_match(1))
|
|
69
|
+
when /\A(?:direction|layout)\s+(TB|TD|BT|LR|RL)\b/i
|
|
70
|
+
direction = Regexp.last_match(1).to_sym
|
|
71
|
+
else
|
|
72
|
+
if (relations = relation_chain(line))
|
|
73
|
+
relations.each do |from_value, _operator, to_value, edge_label|
|
|
74
|
+
from_id, from_label = endpoint(from_value, nodes.length)
|
|
75
|
+
to_id, to_label = endpoint(to_value, nodes.length + 1)
|
|
76
|
+
nodes[from_id] ||= Node.new(id: from_id, label: from_label)
|
|
77
|
+
nodes[to_id] ||= Node.new(id: to_id, label: to_label)
|
|
78
|
+
edges << Edge.new(from: from_id, to: to_id, label: edge_label)
|
|
79
|
+
end
|
|
80
|
+
elsif (match = line.match(/\A(?:group|service)\s+([[:alnum:]_-]+)(?:\([^)]*\))?\[([^\]]+)\]/i))
|
|
81
|
+
nodes[match[1]] ||= Node.new(id: match[1], label: match[2].strip)
|
|
82
|
+
elsif (node = c4_node(line))
|
|
83
|
+
id, label = node
|
|
84
|
+
nodes[id] ||= Node.new(id: id, label: label)
|
|
85
|
+
elsif (relation = c4_relation(line))
|
|
86
|
+
from_id, to_id, label = relation
|
|
87
|
+
nodes[from_id] ||= Node.new(id: from_id, label: from_id)
|
|
88
|
+
nodes[to_id] ||= Node.new(id: to_id, label: to_id)
|
|
89
|
+
edges << Edge.new(from: from_id, to: to_id, label: label)
|
|
90
|
+
elsif (match = line.match(/\A(?:flow|global|connector)\s+([[:alnum:]_.-]+)(?:\[([^\]]+)\])?/i))
|
|
91
|
+
id = match[1]
|
|
92
|
+
nodes[id] ||= Node.new(id: id, label: match[2].to_s.delete_prefix('"').delete_suffix('"').then { |label| label.empty? ? id : label })
|
|
93
|
+
elsif (match = line.match(/\A([[:alnum:]_.-]+)(?:\[([^\]]+)\]|@\{)/))
|
|
94
|
+
id = match[1]
|
|
95
|
+
label = match[2].to_s.delete_prefix('"').delete_suffix('"')
|
|
96
|
+
nodes[id] ||= Node.new(id: id, label: label.empty? ? id : label)
|
|
97
|
+
elsif (match = line.match(DECLARATION))
|
|
98
|
+
id = match[1]
|
|
99
|
+
label = (match[2] || match[3] || match[4])&.strip&.delete_prefix('"')&.delete_suffix('"') || id
|
|
100
|
+
nodes[id] ||= Node.new(id: id, label: label)
|
|
101
|
+
elsif useful_line?(line)
|
|
102
|
+
id = "line#{nodes.length}"
|
|
103
|
+
nodes[id] = Node.new(id: id, label: raw_line)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if nodes.empty? && title
|
|
109
|
+
nodes["title"] = Node.new(id: "title", label: title)
|
|
110
|
+
end
|
|
111
|
+
[Diagram.new(keyword: keyword, title: title, lines: lines.freeze, nodes: nodes.values.freeze,
|
|
112
|
+
edges: edges.freeze, direction: direction), []]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unquote(value)
|
|
116
|
+
text = value.to_s.strip
|
|
117
|
+
return text[1...-1] if text.length >= 2 && ['"', "'"].include?(text[0]) && text[-1] == text[0]
|
|
118
|
+
|
|
119
|
+
text
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def layout(ast, **options)
|
|
123
|
+
native = Specialized.scene(ast, **options)
|
|
124
|
+
return native if native
|
|
125
|
+
|
|
126
|
+
return graph_scene(ast, **options) if ast.edges.any?
|
|
127
|
+
|
|
128
|
+
card_scene(ast)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def graph_scene(ast, **options)
|
|
132
|
+
flow_nodes = ast.nodes.map do |node|
|
|
133
|
+
Merminal::Flowchart::Node.new(id: node.id, label: node.label, shape: :rectangle,
|
|
134
|
+
classes: [].freeze, source_pos: [1, 1])
|
|
135
|
+
end
|
|
136
|
+
flow_edges = ast.edges.each_with_index.map do |edge, index|
|
|
137
|
+
Merminal::Flowchart::Edge.new(id: index, from: edge.from, to: edge.to, label: edge.label,
|
|
138
|
+
stroke: :light, start_marker: nil, end_marker: :arrow,
|
|
139
|
+
minlen: 1, source_pos: [1, 1])
|
|
140
|
+
end
|
|
141
|
+
flow = Merminal::Flowchart::Diagram.new(direction: ast.direction, nodes: flow_nodes.freeze,
|
|
142
|
+
edges: flow_edges.freeze, subgraphs: [].freeze, styles: {}.freeze)
|
|
143
|
+
Merminal::Flowchart.layout(flow, **options)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def card_scene(ast)
|
|
147
|
+
builder = Builder.new
|
|
148
|
+
y = 0
|
|
149
|
+
if ast.title
|
|
150
|
+
builder.text(0, y, ast.title, role: :emphasis)
|
|
151
|
+
y += 2
|
|
152
|
+
end
|
|
153
|
+
width = [ast.lines.map { |line| Merminal::Text.width(line) }.max.to_i + 4, 12].max
|
|
154
|
+
ast.lines.each_with_index do |line, index|
|
|
155
|
+
builder.box(0, y, width, 3, role: :node_border)
|
|
156
|
+
builder.text(2, y + 1, "#{index + 1}. #{line}")
|
|
157
|
+
y += 4
|
|
158
|
+
end
|
|
159
|
+
builder.scene
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def endpoint(value, index)
|
|
163
|
+
text = value.to_s.strip.sub(/\A<?[TBLR]:/, "").sub(/:[TBLR]\z/, "")
|
|
164
|
+
anonymous = text.match(/\A\((.+)\)\z/)
|
|
165
|
+
label_match = text.match(/\A[^\[]+\[(.+)\]\z/) || text.match(/\A[^\(]+\((.+)\)\z/) || text.match(/\A[\"'](.+)[\"']\z/)
|
|
166
|
+
label = anonymous ? anonymous[1] : label_match ? label_match[1] : text
|
|
167
|
+
id = if anonymous
|
|
168
|
+
anonymous[1].gsub(/\W+/, "_")
|
|
169
|
+
else
|
|
170
|
+
text[/[[:alnum:]_][[:alnum:]_.-]*/]
|
|
171
|
+
end || "node#{index}"
|
|
172
|
+
[id, label.to_s.gsub(/<br\s*\/?\s*>/i, "\n")]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def c4_relation(line)
|
|
176
|
+
match = line.match(/\ARelIndex\([^,]+,\s*([^,]+),\s*([^,]+)(?:,\s*["']?([^"')]+)["']?)?/)
|
|
177
|
+
match ||= line.match(C4_RELATION)
|
|
178
|
+
return unless match
|
|
179
|
+
|
|
180
|
+
[match[1].strip, match[2].strip, match[3]&.strip]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def c4_node(line)
|
|
184
|
+
match = line.match(/\A([[:alnum:]_]+)\((.*)\)/)
|
|
185
|
+
return unless match && C4_NODE_TYPES.include?(match[1])
|
|
186
|
+
|
|
187
|
+
arguments = []
|
|
188
|
+
current = +""
|
|
189
|
+
quote = nil
|
|
190
|
+
match[2].each_char do |char|
|
|
191
|
+
if quote
|
|
192
|
+
quote = nil if quote == char
|
|
193
|
+
elsif ['"', "'"].include?(char)
|
|
194
|
+
quote = char
|
|
195
|
+
end
|
|
196
|
+
if char == "," && quote.nil?
|
|
197
|
+
arguments << current
|
|
198
|
+
current = +""
|
|
199
|
+
else
|
|
200
|
+
current << char
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
arguments << current
|
|
204
|
+
return unless arguments[1]
|
|
205
|
+
|
|
206
|
+
label = arguments[1].strip
|
|
207
|
+
label = label[1...-1] if label.length >= 2 && ['"', "'"].include?(label[0]) && label[-1] == label[0]
|
|
208
|
+
[arguments[0].strip, label]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def relation_chain(line)
|
|
212
|
+
if (match = line.match(/\A(.+?)\s+-\s+(.+?)\s+->\s+(.+)\z/))
|
|
213
|
+
return [[match[1], '->', match[3], match[2]]]
|
|
214
|
+
end
|
|
215
|
+
if (match = line.match(/\A(.+?)\s+<-\s+(.+?)\s+-\s+(.+)\z/))
|
|
216
|
+
return [[match[3], '<-', match[1], match[2]]]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
operators = []
|
|
220
|
+
line.scan(EDGE_PATTERN) do
|
|
221
|
+
match = Regexp.last_match
|
|
222
|
+
operators << [match.begin(0), match[0], match.end(0)]
|
|
223
|
+
end
|
|
224
|
+
return if operators.empty?
|
|
225
|
+
|
|
226
|
+
from = line[0...operators.first[0]].strip
|
|
227
|
+
pending_label = nil
|
|
228
|
+
operators.filter_map.with_index do |(_, operator, finish), index|
|
|
229
|
+
target_end = operators[index + 1]&.first || line.length
|
|
230
|
+
target = line[finish...target_end].to_s.strip
|
|
231
|
+
next if target.empty?
|
|
232
|
+
if operators[index + 1] && target.match?(/\A["'].*["']\z/)
|
|
233
|
+
pending_label = target[1...-1]
|
|
234
|
+
next
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
label = pending_label
|
|
238
|
+
pending_label = nil
|
|
239
|
+
if (pipe = target.match(/\A\|(.+?)\|\s+(.+)\z/))
|
|
240
|
+
label = pipe[1]
|
|
241
|
+
target = pipe[2]
|
|
242
|
+
elsif !target.match?(/\A<?[TBLR]:/) && !target.match?(/:[TBLR]\z/) &&
|
|
243
|
+
(colon = target.match(/\A(.+?)\s*:\s*(.+)\z/))
|
|
244
|
+
target = colon[1]
|
|
245
|
+
label = colon[2]
|
|
246
|
+
end
|
|
247
|
+
relation = [from, operator, target, label&.strip]
|
|
248
|
+
from = target
|
|
249
|
+
relation
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def useful_line?(line)
|
|
254
|
+
!line.match?(/\A(?:[}\]]\z|(?:accTitle|accDescr|click|style|classDef|class|linkStyle|config|theme|showData|dateFormat|axisFormat|tickInterval|excludes|section|x-axis|y-axis|bar|line|title|end|align|columns|id|text|risk|verifymethod|docref|type|UpdateElementStyle|UpdateRelStyle|UpdateLayoutConfig|AddElementTag|AddRelTag|Lay_(?:U|Up|D|Down|L|Left|R|Right))\b)/i)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|