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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal
|
|
4
|
+
module Diagrams
|
|
5
|
+
Text = Merminal::Text
|
|
6
|
+
Scene = Merminal::Scene
|
|
7
|
+
Flowchart = Merminal::Flowchart
|
|
8
|
+
|
|
9
|
+
# Shared Scene assembly for diagram plugins.
|
|
10
|
+
class Builder
|
|
11
|
+
def initialize
|
|
12
|
+
@items = []
|
|
13
|
+
@width = 0
|
|
14
|
+
@height = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def text(x, y, string, role: :node_text)
|
|
18
|
+
string.to_s.split("\n", -1).each_with_index do |line, offset|
|
|
19
|
+
@items << Scene::Text.new(x: x, y: y + offset, string: line, role: role, layer: :label, emphasis: nil)
|
|
20
|
+
reach(x + Text.width(line), y + offset + 1)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def box(x, y, width, height, role: :node_border, rounded: false)
|
|
25
|
+
@items << Scene::Box.new(rect: Scene::Rect.new(x: x, y: y, width: width, height: height),
|
|
26
|
+
stroke: Scene::LIGHT, corners: rounded ? :rounded : :sharp, role: role,
|
|
27
|
+
layer: role == :container_border ? :container : :node)
|
|
28
|
+
reach(x + width, y + height)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def line(points, role: :edge, weight: :light, pattern: :solid)
|
|
32
|
+
@items << Scene::Polyline.new(points: points, stroke: Scene::Stroke.new(weight: weight, pattern: pattern),
|
|
33
|
+
role: role, layer: :edge)
|
|
34
|
+
points.each { |x, y| reach(x + 1, y + 1) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def marker(x, y, kind: :arrow, direction: :e)
|
|
38
|
+
@items << Scene::Marker.new(x: x, y: y, kind: kind, direction: direction, role: :marker, layer: :marker)
|
|
39
|
+
reach(x + 1, y + 1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def glyph(x, y, char, role: :series_1)
|
|
43
|
+
@items << Scene::Glyph.new(x: x, y: y, char: char, role: role, layer: :marker)
|
|
44
|
+
reach(x + 1, y + 1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def scene
|
|
48
|
+
Scene.new(width: @width, height: @height, items: @items.freeze)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def reach(x, y)
|
|
54
|
+
@width = [@width, x].max
|
|
55
|
+
@height = [@height, y].max
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def finding(message, source, index, severity: :error)
|
|
62
|
+
Diagnostic.new(severity: severity, message: message, line: source.line_map[index] || 1, column: 1, length: 1)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Merminal::Diagrams
|
|
6
|
+
# Date based Mermaid Gantt chart.
|
|
7
|
+
module Gantt
|
|
8
|
+
Task = Data.define(:name, :state, :start_date, :end_date, :section)
|
|
9
|
+
Diagram = Data.define(:title, :tasks)
|
|
10
|
+
def self.diagram_type = :gantt
|
|
11
|
+
def self.keywords = %w[gantt]
|
|
12
|
+
|
|
13
|
+
def self.parse(source)
|
|
14
|
+
title = source.title
|
|
15
|
+
format = "%Y-%m-%d"
|
|
16
|
+
section = nil
|
|
17
|
+
weekends = false
|
|
18
|
+
tasks = []
|
|
19
|
+
by_id = {}
|
|
20
|
+
findings = []
|
|
21
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
22
|
+
statement = line.strip
|
|
23
|
+
case statement
|
|
24
|
+
when /\Atitle\s+(.+)\z/
|
|
25
|
+
title = Regexp.last_match(1)
|
|
26
|
+
when /\AdateFormat\s+(.+)\z/
|
|
27
|
+
format = Regexp.last_match(1).gsub("YYYY", "%Y").gsub("MM", "%m").gsub("DD", "%d")
|
|
28
|
+
when /\Asection\s+(.+)\z/
|
|
29
|
+
section = Regexp.last_match(1)
|
|
30
|
+
when /\Aexcludes\s+weekends\z/
|
|
31
|
+
weekends = true
|
|
32
|
+
when /\A([^:]+)\s*:\s*(.+)\z/
|
|
33
|
+
name = Regexp.last_match(1).strip
|
|
34
|
+
pieces = Regexp.last_match(2).split(",").map(&:strip)
|
|
35
|
+
state = pieces.grep(/\A(?:done|active|crit|milestone)\z/).first&.to_sym
|
|
36
|
+
pieces.delete(state.to_s) if state
|
|
37
|
+
id = pieces.length > 2 ? pieces.shift : nil
|
|
38
|
+
start_token = pieces.shift
|
|
39
|
+
duration_token = pieces.shift
|
|
40
|
+
start_date = if start_token&.start_with?("after ")
|
|
41
|
+
by_id[start_token.delete_prefix("after ")]&.end_date
|
|
42
|
+
else
|
|
43
|
+
Date.strptime(start_token.to_s, format)
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "unknown start date" unless start_date
|
|
46
|
+
|
|
47
|
+
end_date = if duration_token =~ /\A(\d+)([dw])\z/
|
|
48
|
+
days = Regexp.last_match(1).to_i * (Regexp.last_match(2) == "w" ? 7 : 1)
|
|
49
|
+
advance(start_date, days, weekends)
|
|
50
|
+
else
|
|
51
|
+
Date.strptime(duration_token.to_s, format)
|
|
52
|
+
end
|
|
53
|
+
task = Task.new(name: name, state: state, start_date: start_date, end_date: end_date, section: section)
|
|
54
|
+
tasks << task
|
|
55
|
+
by_id[id] = task if id
|
|
56
|
+
when "", /\A(?:axisFormat|todayMarker|tickInterval)\b/
|
|
57
|
+
next
|
|
58
|
+
else
|
|
59
|
+
findings << Merminal::Diagrams.finding("unrecognized gantt statement", source, index + 1)
|
|
60
|
+
end
|
|
61
|
+
rescue ArgumentError
|
|
62
|
+
findings << Merminal::Diagrams.finding("invalid gantt task", source, index + 1)
|
|
63
|
+
end
|
|
64
|
+
[Diagram.new(title: title, tasks: tasks.freeze), findings]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.advance(start_date, days, weekends)
|
|
68
|
+
date = start_date
|
|
69
|
+
days.times do
|
|
70
|
+
date += 1
|
|
71
|
+
date += 1 while weekends && [0, 6].include?(date.wday)
|
|
72
|
+
end
|
|
73
|
+
date
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.layout(ast, charset: :unicode, **)
|
|
77
|
+
builder = Builder.new
|
|
78
|
+
return builder.scene if ast.tasks.empty?
|
|
79
|
+
|
|
80
|
+
y = 0
|
|
81
|
+
if ast.title
|
|
82
|
+
builder.text(0, y, ast.title, role: :emphasis)
|
|
83
|
+
y += 2
|
|
84
|
+
end
|
|
85
|
+
first = ast.tasks.map(&:start_date).min
|
|
86
|
+
last = ast.tasks.map(&:end_date).max
|
|
87
|
+
span = [(last - first).to_i, 1].max
|
|
88
|
+
label_width = ast.tasks.map { |task| Text.width(task.name) }.max
|
|
89
|
+
bar_left = label_width + 3
|
|
90
|
+
builder.text(bar_left, y, "#{first} — #{last}", role: :axis_label)
|
|
91
|
+
y += 1
|
|
92
|
+
section = nil
|
|
93
|
+
ast.tasks.each do |task|
|
|
94
|
+
if task.section && task.section != section
|
|
95
|
+
section = task.section
|
|
96
|
+
builder.text(0, y, section, role: :container_title)
|
|
97
|
+
y += 1
|
|
98
|
+
end
|
|
99
|
+
builder.text(0, y, Text.pad(task.name, label_width))
|
|
100
|
+
offset = ((task.start_date - first).to_f / span * 40).round
|
|
101
|
+
size = [((task.end_date - task.start_date).to_f / span * 40).round, 1].max
|
|
102
|
+
char = task.state == :milestone ? (charset == :ascii ? "*" : "◆") : (charset == :ascii ? "#" : "█")
|
|
103
|
+
builder.text(bar_left + offset, y, char * (task.state == :milestone ? 1 : size), role: task.state == :done ? :muted : :series_1)
|
|
104
|
+
y += 1
|
|
105
|
+
end
|
|
106
|
+
builder.scene
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Indented Mermaid mindmap rendered as a tidy tree.
|
|
5
|
+
module Mindmap
|
|
6
|
+
Node = Data.define(:id, :label, :parent, :depth, :shape)
|
|
7
|
+
Diagram = Data.define(:nodes)
|
|
8
|
+
def self.diagram_type = :mindmap
|
|
9
|
+
def self.keywords = %w[mindmap]
|
|
10
|
+
|
|
11
|
+
def self.parse(source)
|
|
12
|
+
nodes = []
|
|
13
|
+
stack = []
|
|
14
|
+
findings = []
|
|
15
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
16
|
+
next if line.strip.empty?
|
|
17
|
+
|
|
18
|
+
indent = line[/\A\s*/].length
|
|
19
|
+
while stack.any? && stack.last[0] >= indent
|
|
20
|
+
stack.pop
|
|
21
|
+
end
|
|
22
|
+
if nodes.any? && stack.empty?
|
|
23
|
+
findings << Merminal::Diagrams.finding("mindmap node has no parent", source, index + 1)
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
raw = line.strip
|
|
27
|
+
shape, label = if raw =~ /\A(.+?)\)\)(.+)\(\(\z/
|
|
28
|
+
[:cloud, Regexp.last_match(2)]
|
|
29
|
+
elsif raw =~ /\A(.+?)\(\((.+)\)\)\z/
|
|
30
|
+
[:circle, Regexp.last_match(2)]
|
|
31
|
+
elsif raw =~ /\A(.+?)\[(.+)\]\z/
|
|
32
|
+
[:rectangle, Regexp.last_match(2)]
|
|
33
|
+
elsif raw =~ /\A(.+?)\((.+)\)\z/
|
|
34
|
+
[:rounded, Regexp.last_match(2)]
|
|
35
|
+
else
|
|
36
|
+
[:rectangle, raw]
|
|
37
|
+
end
|
|
38
|
+
id = nodes.length
|
|
39
|
+
nodes << Node.new(id: id, label: label, parent: stack.last&.last, depth: stack.length, shape: shape)
|
|
40
|
+
stack << [indent, id]
|
|
41
|
+
end
|
|
42
|
+
[Diagram.new(nodes: nodes.freeze), findings]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.layout(ast, **)
|
|
46
|
+
builder = Builder.new
|
|
47
|
+
return builder.scene if ast.nodes.empty?
|
|
48
|
+
|
|
49
|
+
children = ast.nodes.to_h { |node| [node.id, []] }
|
|
50
|
+
ast.nodes.each { |node| children[node.parent] << node.id if node.parent }
|
|
51
|
+
depth_widths = ast.nodes.group_by(&:depth).transform_values do |nodes|
|
|
52
|
+
nodes.map { |node| node_width(node) }.max
|
|
53
|
+
end
|
|
54
|
+
x_positions = {}
|
|
55
|
+
depth_widths.keys.sort.each { |depth| x_positions[depth] = depth.zero? ? 0 : x_positions[depth - 1] + depth_widths[depth - 1] + 5 }
|
|
56
|
+
y_positions = {}
|
|
57
|
+
next_leaf = 0
|
|
58
|
+
place = lambda do |id|
|
|
59
|
+
if children[id].empty?
|
|
60
|
+
y_positions[id] = next_leaf * 4
|
|
61
|
+
next_leaf += 1
|
|
62
|
+
else
|
|
63
|
+
children[id].each { |child| place.call(child) }
|
|
64
|
+
y_positions[id] = (y_positions[children[id].first] + y_positions[children[id].last]) / 2
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
place.call(ast.nodes.first.id)
|
|
68
|
+
ast.nodes.each do |node|
|
|
69
|
+
x, y = x_positions[node.depth], y_positions[node.id]
|
|
70
|
+
width = node_width(node)
|
|
71
|
+
builder.box(x, y, width, 3, rounded: node.shape != :rectangle)
|
|
72
|
+
builder.text(x + (width - Text.width(node.label)) / 2, y + 1, node.label)
|
|
73
|
+
if %i[circle cloud].include?(node.shape)
|
|
74
|
+
left, right = node.shape == :circle ? %w[( )] : %w[~ ~]
|
|
75
|
+
builder.glyph(x, y + 1, left, role: :node_border)
|
|
76
|
+
builder.glyph(x + width - 1, y + 1, right, role: :node_border)
|
|
77
|
+
end
|
|
78
|
+
next unless node.parent
|
|
79
|
+
|
|
80
|
+
parent = ast.nodes[node.parent]
|
|
81
|
+
px = x_positions[parent.depth] + node_width(parent) - 1
|
|
82
|
+
py = y_positions[parent.id] + 1
|
|
83
|
+
cy = y + 1
|
|
84
|
+
middle = (px + x) / 2
|
|
85
|
+
builder.line([[px, py], [middle, py], [middle, cy], [x, cy]])
|
|
86
|
+
end
|
|
87
|
+
builder.scene
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.node_width(node)
|
|
91
|
+
Text.width(node.label) + (%i[circle cloud].include?(node.shape) ? 6 : 4)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Mermaid pie data as proportional horizontal bars.
|
|
5
|
+
module Pie
|
|
6
|
+
Diagram = Data.define(:title, :show_data, :entries)
|
|
7
|
+
def self.diagram_type = :pie
|
|
8
|
+
def self.keywords = %w[pie]
|
|
9
|
+
|
|
10
|
+
def self.parse(source)
|
|
11
|
+
title = source.title
|
|
12
|
+
show_data = source.lines.first.to_s.include?("showData")
|
|
13
|
+
entries = []
|
|
14
|
+
findings = []
|
|
15
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
16
|
+
case line.strip
|
|
17
|
+
when /\Atitle\s+(.+)\z/i
|
|
18
|
+
title = Regexp.last_match(1)
|
|
19
|
+
when /\AshowData\z/i
|
|
20
|
+
show_data = true
|
|
21
|
+
when /\A["'](.+?)["']\s*:\s*(-?\d+(?:\.\d+)?)\z/
|
|
22
|
+
value = Regexp.last_match(2).to_f
|
|
23
|
+
if value.negative?
|
|
24
|
+
findings << Merminal::Diagrams.finding("pie values must be nonnegative", source, index + 1)
|
|
25
|
+
else
|
|
26
|
+
entries << [Regexp.last_match(1), value]
|
|
27
|
+
end
|
|
28
|
+
when ""
|
|
29
|
+
next
|
|
30
|
+
else
|
|
31
|
+
findings << Merminal::Diagrams.finding("unrecognized pie statement", source, index + 1)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
[Diagram.new(title: title, show_data: show_data, entries: entries.freeze), findings]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.layout(ast, charset: :unicode, **)
|
|
38
|
+
builder = Builder.new
|
|
39
|
+
y = 0
|
|
40
|
+
if ast.title
|
|
41
|
+
builder.text(0, y, ast.title, role: :emphasis)
|
|
42
|
+
y += 2
|
|
43
|
+
end
|
|
44
|
+
total = ast.entries.sum(&:last)
|
|
45
|
+
label_width = ast.entries.map { |label, _| Text.width(label) }.max.to_i
|
|
46
|
+
ast.entries.each do |label, value|
|
|
47
|
+
fraction = total.zero? ? 0 : value / total
|
|
48
|
+
eighths = (fraction * 30 * 8).round
|
|
49
|
+
bar = if charset == :ascii
|
|
50
|
+
"#" * (eighths / 8) + (eighths % 8 > 0 ? "+" : "")
|
|
51
|
+
else
|
|
52
|
+
"█" * (eighths / 8) + (eighths % 8 > 0 ? %w[▏ ▎ ▍ ▌ ▋ ▊ ▉][eighths % 8 - 1] : "")
|
|
53
|
+
end
|
|
54
|
+
builder.text(0, y, Text.pad(label, label_width))
|
|
55
|
+
builder.text(label_width + 2, y, bar, role: :series_1)
|
|
56
|
+
suffix = format("%5.1f%%", fraction * 100)
|
|
57
|
+
suffix += " (#{format('%g', value)})" if ast.show_data
|
|
58
|
+
builder.text(label_width + 34, y, suffix, role: :axis_label)
|
|
59
|
+
y += 1
|
|
60
|
+
end
|
|
61
|
+
builder.scene
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Participants and chronological events of a Mermaid sequence diagram.
|
|
5
|
+
module Sequence
|
|
6
|
+
Participant = Data.define(:id, :label, :actor)
|
|
7
|
+
Event = Data.define(:kind, :from, :to, :text, :extra)
|
|
8
|
+
Diagram = Data.define(:participants, :events)
|
|
9
|
+
MESSAGE = /\A([\w.]+(?:-(?![-=>x])[\w.]+)*)\s*(-->>|->>|-->|->|--x|-x|--\)|-\))\s*([+-]?)([\w.-]+)\s*:\s*(.*)\z/
|
|
10
|
+
def self.diagram_type = :sequence
|
|
11
|
+
def self.keywords = %w[sequenceDiagram]
|
|
12
|
+
|
|
13
|
+
def self.parse(source)
|
|
14
|
+
participants = {}
|
|
15
|
+
events = []
|
|
16
|
+
findings = []
|
|
17
|
+
blocks = []
|
|
18
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
19
|
+
statement = line.strip
|
|
20
|
+
case statement
|
|
21
|
+
when /\A(participant|actor)\s+(\S+)(?:\s+as\s+(.+))?\z/
|
|
22
|
+
participants[Regexp.last_match(2)] = Participant.new(id: Regexp.last_match(2),
|
|
23
|
+
label: Regexp.last_match(3) || Regexp.last_match(2),
|
|
24
|
+
actor: Regexp.last_match(1) == "actor")
|
|
25
|
+
when MESSAGE
|
|
26
|
+
from, operator, activation, to, text = Regexp.last_match.captures
|
|
27
|
+
[from, to].each { |id| participants[id] ||= Participant.new(id: id, label: id, actor: false) }
|
|
28
|
+
events << Event.new(kind: :message, from: from, to: to, text: text, extra: operator)
|
|
29
|
+
events << Event.new(kind: activation == "+" ? :activate : :deactivate,
|
|
30
|
+
from: activation == "+" ? to : from, to: nil, text: nil, extra: nil) unless activation.empty?
|
|
31
|
+
when /\ANote\s+(left of|right of|over)\s+([^:]+):\s*(.*)\z/i
|
|
32
|
+
side, ids, text = Regexp.last_match.captures
|
|
33
|
+
ids.split(",").each { |id| participants[id.strip] ||= Participant.new(id: id.strip, label: id.strip, actor: false) }
|
|
34
|
+
events << Event.new(kind: :note, from: ids.split(",").first.strip, to: ids.split(",").last.strip,
|
|
35
|
+
text: text.gsub(/<br\s*\/?\s*>/i, "\n"), extra: side.downcase)
|
|
36
|
+
when /\A(loop|alt|opt|par|critical|break|rect)\b\s*(.*)\z/i
|
|
37
|
+
blocks << Regexp.last_match(1)
|
|
38
|
+
events << Event.new(kind: :block_start, from: nil, to: nil, text: Regexp.last_match(2), extra: Regexp.last_match(1))
|
|
39
|
+
when /\A(else|and)\b\s*(.*)\z/i
|
|
40
|
+
if blocks.empty?
|
|
41
|
+
findings << Merminal::Diagrams.finding("unexpected sequence branch", source, index + 1)
|
|
42
|
+
else
|
|
43
|
+
events << Event.new(kind: :block_else, from: nil, to: nil, text: Regexp.last_match(2), extra: Regexp.last_match(1))
|
|
44
|
+
end
|
|
45
|
+
when "end"
|
|
46
|
+
if blocks.empty?
|
|
47
|
+
findings << Merminal::Diagrams.finding("unexpected end", source, index + 1)
|
|
48
|
+
else
|
|
49
|
+
blocks.pop
|
|
50
|
+
events << Event.new(kind: :block_end, from: nil, to: nil, text: nil, extra: nil)
|
|
51
|
+
end
|
|
52
|
+
when /\A(activate|deactivate)\s+(\S+)\z/i
|
|
53
|
+
id = Regexp.last_match(2)
|
|
54
|
+
participants[id] ||= Participant.new(id: id, label: id, actor: false)
|
|
55
|
+
events << Event.new(kind: Regexp.last_match(1).downcase.to_sym, from: id, to: nil, text: nil, extra: nil)
|
|
56
|
+
when /\Aautonumber\b/
|
|
57
|
+
events << Event.new(kind: :autonumber, from: nil, to: nil, text: nil, extra: nil)
|
|
58
|
+
when ""
|
|
59
|
+
next
|
|
60
|
+
else
|
|
61
|
+
findings << Merminal::Diagrams.finding("unrecognized sequence statement", source, index + 1)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
findings << Merminal::Diagrams.finding("unclosed sequence block", source, source.lines.length - 1) unless blocks.empty?
|
|
65
|
+
[Diagram.new(participants: participants.values.freeze, events: events.freeze), findings]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.layout(ast, repeat_participants: false, **)
|
|
69
|
+
builder = Builder.new
|
|
70
|
+
return builder.scene if ast.participants.empty?
|
|
71
|
+
|
|
72
|
+
ids = ast.participants.map(&:id)
|
|
73
|
+
gaps = ast.participants.each_cons(2).map do |left, right|
|
|
74
|
+
[14, (Text.width(left.label) + 5) / 2 + (Text.width(right.label) + 5) / 2 + 4].max
|
|
75
|
+
end
|
|
76
|
+
numbered = false
|
|
77
|
+
ast.events.each do |event|
|
|
78
|
+
numbered = true if event.kind == :autonumber
|
|
79
|
+
next unless event.kind == :message && event.from != event.to
|
|
80
|
+
|
|
81
|
+
a, b = [ids.index(event.from), ids.index(event.to)].sort
|
|
82
|
+
needed = Text.width(event.text) + 4 + (numbered ? 6 : 0)
|
|
83
|
+
current = gaps[a...b].sum
|
|
84
|
+
gaps[b - 1] += needed - current if current < needed
|
|
85
|
+
end
|
|
86
|
+
left_note = ast.events.select { |event| event.kind == :note && event.extra == "left of" && event.from == ids.first }
|
|
87
|
+
left_margin = left_note.map { |event| Text.width(event.text) + 4 }.max.to_i
|
|
88
|
+
first_width = Text.width(ast.participants.first.label) + 4
|
|
89
|
+
centers = [[5 + left_margin, first_width / 2 + 1].max]
|
|
90
|
+
gaps.each { |gap| centers << centers[-1] + gap }
|
|
91
|
+
columns = ids.zip(centers).to_h
|
|
92
|
+
rows = []
|
|
93
|
+
y = 4
|
|
94
|
+
autonumber = false
|
|
95
|
+
number = 0
|
|
96
|
+
ast.events.each do |event|
|
|
97
|
+
next autonumber = true if event.kind == :autonumber
|
|
98
|
+
|
|
99
|
+
label = event.text
|
|
100
|
+
if event.kind == :message && autonumber
|
|
101
|
+
number += 1
|
|
102
|
+
label = "#{number}. #{label}"
|
|
103
|
+
end
|
|
104
|
+
rows << [event, y, label]
|
|
105
|
+
y += case event.kind
|
|
106
|
+
when :message then event.from == event.to ? 4 : 3
|
|
107
|
+
when :note then label.count("\n") + 4
|
|
108
|
+
when :block_start, :block_else then 2
|
|
109
|
+
else 1
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
bottom = y + 2
|
|
113
|
+
ast.participants.each do |participant|
|
|
114
|
+
center = columns.fetch(participant.id)
|
|
115
|
+
width = Text.width(participant.label) + 4
|
|
116
|
+
x = center - width / 2
|
|
117
|
+
builder.box(x, 0, width, 3, rounded: participant.actor)
|
|
118
|
+
builder.text(x + 2, 1, participant.label)
|
|
119
|
+
builder.line([[center, 3], [center, bottom]], role: :muted, pattern: :dotted)
|
|
120
|
+
end
|
|
121
|
+
active = Hash.new { |hash, key| hash[key] = [] }
|
|
122
|
+
blocks = []
|
|
123
|
+
rows.each do |event, row, label|
|
|
124
|
+
case event.kind
|
|
125
|
+
when :message
|
|
126
|
+
from = columns.fetch(event.from)
|
|
127
|
+
to = columns.fetch(event.to)
|
|
128
|
+
if from == to
|
|
129
|
+
builder.text(from + 2, row, label, role: :edge_label)
|
|
130
|
+
builder.line([[from, row + 1], [from + 5, row + 1], [from + 5, row + 3], [from, row + 3]],
|
|
131
|
+
pattern: event.extra.start_with?("--") ? :dotted : :solid)
|
|
132
|
+
builder.marker(from + 1, row + 3, direction: :w, kind: marker_kind(event.extra))
|
|
133
|
+
else
|
|
134
|
+
left = [from, to].min
|
|
135
|
+
builder.text(left + 1, row, label, role: :edge_label)
|
|
136
|
+
builder.line([[from, row + 1], [to, row + 1]], pattern: event.extra.start_with?("--") ? :dotted : :solid)
|
|
137
|
+
builder.marker(to + (to > from ? -1 : 1), row + 1, direction: to > from ? :e : :w,
|
|
138
|
+
kind: marker_kind(event.extra))
|
|
139
|
+
end
|
|
140
|
+
when :note
|
|
141
|
+
from = columns.fetch(event.from)
|
|
142
|
+
to = columns.fetch(event.to)
|
|
143
|
+
width = [label.split("\n").map { |part| Text.width(part) }.max.to_i + 4,
|
|
144
|
+
event.extra == "over" ? (to - from).abs + 5 : 0].max
|
|
145
|
+
x = event.extra == "left of" ? from - width - 2 : event.extra == "right of" ? to + 2 : (from + to - width) / 2
|
|
146
|
+
x = [x, 0].max
|
|
147
|
+
builder.box(x, row, width, label.count("\n") + 3, role: :container_border)
|
|
148
|
+
builder.text(x + 2, row + 1, label)
|
|
149
|
+
when :block_start
|
|
150
|
+
blocks << [row, event.extra, label, blocks.length]
|
|
151
|
+
when :block_else
|
|
152
|
+
inset = blocks.length - 1
|
|
153
|
+
builder.line([[inset, row], [centers.last + 6 - inset, row]], role: :container_border, pattern: :dotted)
|
|
154
|
+
builder.text(inset + 2, row + 1, "#{event.extra} #{label}", role: :container_title)
|
|
155
|
+
when :block_end
|
|
156
|
+
start, kind, title, inset = blocks.pop
|
|
157
|
+
next unless start
|
|
158
|
+
|
|
159
|
+
builder.box(inset, start, centers.last + 7 - inset * 2, row - start + 1, role: :container_border)
|
|
160
|
+
builder.text(inset + 2, start, "#{kind} #{title}", role: :container_title)
|
|
161
|
+
when :activate
|
|
162
|
+
active[event.from] << [row, active[event.from].length]
|
|
163
|
+
when :deactivate
|
|
164
|
+
start, depth = active[event.from].pop
|
|
165
|
+
builder.box(columns.fetch(event.from) - 1 + depth * 2, start, 3, row - start + 1) if start
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
active.each do |id, starts|
|
|
169
|
+
starts.each { |start, depth| builder.box(columns.fetch(id) - 1 + depth * 2, start, 3, bottom - start + 1) }
|
|
170
|
+
end
|
|
171
|
+
if repeat_participants
|
|
172
|
+
ast.participants.each do |participant|
|
|
173
|
+
center = columns.fetch(participant.id)
|
|
174
|
+
width = Text.width(participant.label) + 4
|
|
175
|
+
builder.box(center - width / 2, bottom + 1, width, 3, rounded: participant.actor)
|
|
176
|
+
builder.text(center - width / 2 + 2, bottom + 2, participant.label)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
builder.scene
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.marker_kind(operator)
|
|
183
|
+
operator.end_with?("x") ? :cross : operator.end_with?(")") ? :circle : :arrow
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|