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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +85 -0
  5. data/docs/adr/000-template.md +24 -0
  6. data/docs/adr/001-runtime.md +19 -0
  7. data/docs/adr/002-scene.md +18 -0
  8. data/docs/adr/003-layout.md +20 -0
  9. data/docs/adr/004-parser.md +19 -0
  10. data/docs/adr/005-public-api.md +23 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/syntax/additional.md +58 -0
  13. data/docs/syntax/class.md +3 -0
  14. data/docs/syntax/er.md +3 -0
  15. data/docs/syntax/flowchart.md +16 -0
  16. data/docs/syntax/gantt.md +3 -0
  17. data/docs/syntax/mindmap.md +3 -0
  18. data/docs/syntax/pie.md +3 -0
  19. data/docs/syntax/sequence.md +14 -0
  20. data/docs/syntax/state.md +3 -0
  21. data/docs/syntax/timeline.md +3 -0
  22. data/docs/syntax/xychart.md +3 -0
  23. data/exe/merminal +7 -0
  24. data/lib/merminal/cli.rb +94 -0
  25. data/lib/merminal/diagrams/additional.rb +257 -0
  26. data/lib/merminal/diagrams/additional_layouts.rb +1274 -0
  27. data/lib/merminal/diagrams/base.rb +65 -0
  28. data/lib/merminal/diagrams/gantt.rb +109 -0
  29. data/lib/merminal/diagrams/mindmap.rb +94 -0
  30. data/lib/merminal/diagrams/pie.rb +64 -0
  31. data/lib/merminal/diagrams/sequence.rb +186 -0
  32. data/lib/merminal/diagrams/structure.rb +316 -0
  33. data/lib/merminal/diagrams/timeline.rb +60 -0
  34. data/lib/merminal/diagrams/xychart.rb +152 -0
  35. data/lib/merminal/flowchart/layout.rb +734 -0
  36. data/lib/merminal/flowchart.rb +279 -0
  37. data/lib/merminal/markdown.rb +37 -0
  38. data/lib/merminal/output.rb +93 -0
  39. data/lib/merminal/raster/box_drawing_table.rb +6 -0
  40. data/lib/merminal/raster.rb +175 -0
  41. data/lib/merminal/scene.rb +37 -0
  42. data/lib/merminal/shareable.rb +9 -0
  43. data/lib/merminal/source.rb +78 -0
  44. data/lib/merminal/text/east_asian_width_table.rb +8 -0
  45. data/lib/merminal/text.rb +110 -0
  46. data/lib/merminal/version.rb +5 -0
  47. data/lib/merminal.rb +122 -0
  48. data/sig/merminal.rbs +59 -0
  49. 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