railroad_diagrams 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Skip < DiagramItem
5
+ def initialize
6
+ super('g')
7
+ @width = 0
8
+ @up = 0
9
+ @down = 0
10
+ end
11
+
12
+ def to_s
13
+ 'Skip()'
14
+ end
15
+
16
+ def format(x, y, width)
17
+ Path.new(x, y).right(width).add(self)
18
+ self
19
+ end
20
+
21
+ def text_diagram
22
+ line, = TextDiagram.get_parts(['line'])
23
+ TextDiagram.new(0, 0, [line])
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Stack < DiagramMultiContainer
5
+ def initialize(*items)
6
+ super('g', items)
7
+ @need_space = false
8
+ @width = @items.map { |item| item.width + (item.needs_space ? 20 : 0) }.max
9
+
10
+ # pretty sure that space calc is totes wrong
11
+ @width += AR * 2 if @items.size > 1
12
+
13
+ @up = @items.first.up
14
+ @down = @items.last.down
15
+ @height = 0
16
+ last = @items.size - 1
17
+
18
+ @items.each_with_index do |item, i|
19
+ @height += item.height
20
+ @height += [AR * 2, item.up + VS].max if i.positive?
21
+ @height += [AR * 2, item.down + VS].max if i < last
22
+ end
23
+ end
24
+
25
+ def to_s
26
+ items = @items.map(&:to_s).join(', ')
27
+ "Stack(#{items})"
28
+ end
29
+
30
+ def format(x, y, width)
31
+ left_gap, right_gap = determine_gaps(width, @width)
32
+ Path.new(x, y).h(left_gap).add(self)
33
+ x += left_gap
34
+ x_initial = x
35
+ if @items.size > 1
36
+ Path.new(x, y).h(AR).add(self)
37
+ x += AR
38
+ inner_width = @width - (AR * 2)
39
+ else
40
+ inner_width = @width
41
+ end
42
+
43
+ @items.each_with_index do |item, i|
44
+ item.format(x, y, inner_width).add(self)
45
+ x += inner_width
46
+ y += item.height
47
+ next unless i != @items.size - 1
48
+
49
+ Path.new(x, y)
50
+ .arc('ne')
51
+ .down([0, item.down + VS - (AR * 2)].max)
52
+ .arc('es')
53
+ .left(inner_width)
54
+ .arc('nw')
55
+ .down([0, @items[i + 1].up + VS - (AR * 2)].max)
56
+ .arc('ws')
57
+ .add(self)
58
+ y += [item.down + VS, AR * 2].max + [@items[i + 1].up + VS, AR * 2].max
59
+ x = x_initial + AR
60
+ end
61
+ if @items.size > 1
62
+ Path.new(x, y).h(AR).add(self)
63
+ x += AR
64
+ end
65
+ Path.new(x, y).h(right_gap).add(self)
66
+ self
67
+ end
68
+
69
+ def text_diagram
70
+ corner_bot_left, corner_bot_right, corner_top_left, corner_top_right, line, line_vertical = TextDiagram.get_parts(
71
+ %w[corner_bot_left corner_bot_right corner_top_left corner_top_right line line_vertical]
72
+ )
73
+
74
+ # Format all the child items, so we can know the maximum width.
75
+ item_tds = @items.map(&:text_diagram)
76
+ max_width = item_tds.map(&:width).max
77
+ left_lines = []
78
+ right_lines = []
79
+ separator_td = TextDiagram.new(0, 0, [line * max_width])
80
+ diagram_td = nil # Top item will replace it.
81
+ item_tds.each_with_index do |item_td, item_num|
82
+ if item_num.zero?
83
+ # The top item enters directly from its left.
84
+ left_lines += [line * 2]
85
+ left_lines += [' ' * 2] * (item_td.height - item_td.entry - 1)
86
+ else
87
+ # All items below the top enter from a snake-line from the previous item's exit.
88
+ # Here, we resume that line, already having descended from above on the right.
89
+ diagram_td = diagram_td.append_below(separator_td, [])
90
+ left_lines += [corner_top_left + line]
91
+ left_lines += ["#{line_vertical} "] * item_td.entry
92
+ left_lines += [corner_bot_left + line]
93
+ left_lines += [' ' * 2] * (item_td.height - item_td.entry - 1)
94
+ right_lines += [' ' * 2] * item_td.exit
95
+ end
96
+ if item_num < item_tds.size - 1
97
+ # All items above the bottom exit via a snake-line to the next item's entry.
98
+ # Here, we start that line on the right.
99
+ right_lines += [line + corner_top_right]
100
+ right_lines += [" #{line_vertical}"] * (item_td.height - item_td.exit - 1)
101
+ right_lines += [line + corner_bot_right]
102
+ else
103
+ # The bottom item exits directly to its right.
104
+ right_lines += [line * 2]
105
+ end
106
+ left_pad, right_pad = TextDiagram._gaps(max_width, item_td.width)
107
+ item_td = item_td.expand(left_pad, right_pad, 0, 0)
108
+ diagram_td = if item_num.zero?
109
+ item_td
110
+ else
111
+ diagram_td.append_below(item_td, [])
112
+ end
113
+ end
114
+ left_td = TextDiagram.new(0, 0, left_lines)
115
+ diagram_td = left_td.append_right(diagram_td, '')
116
+ right_td = TextDiagram.new(0, right_lines.size - 1, right_lines)
117
+ diagram_td.append_right(right_td, '')
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Start < DiagramItem
5
+ def initialize(type = 'simple', label: nil)
6
+ super('g')
7
+ @width =
8
+ if label
9
+ [20, (label.length * CHAR_WIDTH) + 10].max
10
+ else
11
+ 20
12
+ end
13
+ @up = 10
14
+ @down = 10
15
+ @type = type
16
+ @label = label
17
+ end
18
+
19
+ def to_s
20
+ "Start(#{@type}, label=#{@label})"
21
+ end
22
+
23
+ def format(x, y, _width)
24
+ path = Path.new(x, y - 10)
25
+ if @type == 'complex'
26
+ path.down(20).m(0, -10).right(@width).add(self)
27
+ else
28
+ path.down(20).m(10, -20).down(20).m(-10, -10).right(@width).add(self)
29
+ end
30
+ if @label
31
+ DiagramItem.new(
32
+ 'text',
33
+ attrs: {
34
+ 'x' => x,
35
+ 'y' => y - 15,
36
+ 'style' => 'text-anchor:start'
37
+ },
38
+ text: @label
39
+ ).add(self)
40
+ end
41
+ self
42
+ end
43
+
44
+ def text_diagram
45
+ cross, line, tee_right = TextDiagram.get_parts(%w[cross line tee_right])
46
+ start =
47
+ if @type == 'simple'
48
+ tee_right + cross + line
49
+ else
50
+ tee_right + line
51
+ end
52
+
53
+ label_td = TextDiagram.new(0, 0, [])
54
+ if @label
55
+ label_td = TextDiagram.new(0, 0, [@label])
56
+ start = TextDiagram.pad_r(start, label_td.width, line)
57
+ end
58
+ start_td = TextDiagram.new(0, 0, [start])
59
+ label_td.append_below(start_td, [], move_entry: true, move_exit: true)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Style
5
+ def initialize(css)
6
+ @css = css
7
+ end
8
+
9
+ class << self
10
+ def default_style
11
+ <<~CSS
12
+ svg.railroad-diagram {
13
+ background-color:hsl(30,20%,95%);
14
+ }
15
+ svg.railroad-diagram path {
16
+ stroke-width:3;
17
+ stroke:black;
18
+ fill:rgba(0,0,0,0);
19
+ }
20
+ svg.railroad-diagram text {
21
+ font:bold 14px monospace;
22
+ text-anchor:middle;
23
+ }
24
+ svg.railroad-diagram text.label{
25
+ text-anchor:start;
26
+ }
27
+ svg.railroad-diagram text.comment{
28
+ font:italic 12px monospace;
29
+ }
30
+ svg.railroad-diagram rect{
31
+ stroke-width:3;
32
+ stroke:black;
33
+ fill:hsl(120,100%,90%);
34
+ }
35
+ svg.railroad-diagram rect.group-box {
36
+ stroke: gray;
37
+ stroke-dasharray: 10 5;
38
+ fill: none;
39
+ }
40
+ CSS
41
+ end
42
+ end
43
+
44
+ def to_s
45
+ "Style(#{@css})"
46
+ end
47
+
48
+ def add(parent)
49
+ parent.children.push(self)
50
+ self
51
+ end
52
+
53
+ def format
54
+ self
55
+ end
56
+
57
+ def text_diagram
58
+ TextDiagram.new
59
+ end
60
+
61
+ def write_svg(write)
62
+ # Write included stylesheet as CDATA. See https://developer.mozilla.org/en-US/docs/Web/SVG/Element/style
63
+ cdata = "/* <![CDATA[ */\n#{@css}\n/* ]]> */\n"
64
+ write.call("<style>#{cdata}</style>")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Terminal < DiagramItem
5
+ def initialize(text, href = nil, title = nil, cls: '')
6
+ super('g', attrs: { 'class' => "terminal #{cls}" })
7
+ @text = text
8
+ @href = href
9
+ @title = title
10
+ @cls = cls
11
+ @width = (text.length * CHAR_WIDTH) + 20
12
+ @up = 11
13
+ @down = 11
14
+ @needs_space = true
15
+ end
16
+
17
+ def to_s
18
+ "Terminal(#{@text}, href=#{@href}, title=#{@title}, cls=#{@cls})"
19
+ end
20
+
21
+ def format(x, y, width)
22
+ left_gap, right_gap = determine_gaps(width, @width)
23
+
24
+ # Hook up the two sides if self is narrower than its stated width.
25
+ Path.new(x, y).h(left_gap).add(self)
26
+ Path.new(x + left_gap + @width, y).h(right_gap).add(self)
27
+
28
+ DiagramItem.new(
29
+ 'rect',
30
+ attrs: {
31
+ 'x' => x + left_gap,
32
+ 'y' => y - 11,
33
+ 'width' => @width,
34
+ 'height' => @up + @down,
35
+ 'rx' => 10,
36
+ 'ry' => 10
37
+ }
38
+ ).add(self)
39
+
40
+ text = DiagramItem.new(
41
+ 'text',
42
+ attrs: {
43
+ 'x' => x + left_gap + (@width / 2),
44
+ 'y' => y + 4
45
+ },
46
+ text: @text
47
+ )
48
+ if @href
49
+ a = DiagramItem.new('a', attrs: { 'xlink:href' => @href }, text:).add(self)
50
+ text.add(a)
51
+ else
52
+ text.add(self)
53
+ end
54
+ DiagramItem.new('title', attrs: {}, text: @title).add(self) if @title
55
+ self
56
+ end
57
+
58
+ def text_diagram
59
+ # NOTE: href, title, and cls are ignored for text diagrams.
60
+ TextDiagram.round_rect(@text)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class TextDiagram
5
+ PARTS_UNICODE = {
6
+ 'cross_diag' => '╳',
7
+ 'corner_bot_left' => '└',
8
+ 'corner_bot_right' => '┘',
9
+ 'corner_top_left' => '┌',
10
+ 'corner_top_right' => '┐',
11
+ 'cross' => '┼',
12
+ 'left' => '│',
13
+ 'line' => '─',
14
+ 'line_vertical' => '│',
15
+ 'multi_repeat' => '↺',
16
+ 'rect_bot' => '─',
17
+ 'rect_bot_dashed' => '┄',
18
+ 'rect_bot_left' => '└',
19
+ 'rect_bot_right' => '┘',
20
+ 'rect_left' => '│',
21
+ 'rect_left_dashed' => '┆',
22
+ 'rect_right' => '│',
23
+ 'rect_right_dashed' => '┆',
24
+ 'rect_top' => '─',
25
+ 'rect_top_dashed' => '┄',
26
+ 'rect_top_left' => '┌',
27
+ 'rect_top_right' => '┐',
28
+ 'repeat_bot_left' => '╰',
29
+ 'repeat_bot_right' => '╯',
30
+ 'repeat_left' => '│',
31
+ 'repeat_right' => '│',
32
+ 'repeat_top_left' => '╭',
33
+ 'repeat_top_right' => '╮',
34
+ 'right' => '│',
35
+ 'roundcorner_bot_left' => '╰',
36
+ 'roundcorner_bot_right' => '╯',
37
+ 'roundcorner_top_left' => '╭',
38
+ 'roundcorner_top_right' => '╮',
39
+ 'roundrect_bot' => '─',
40
+ 'roundrect_bot_dashed' => '┄',
41
+ 'roundrect_bot_left' => '╰',
42
+ 'roundrect_bot_right' => '╯',
43
+ 'roundrect_left' => '│',
44
+ 'roundrect_left_dashed' => '┆',
45
+ 'roundrect_right' => '│',
46
+ 'roundrect_right_dashed' => '┆',
47
+ 'roundrect_top' => '─',
48
+ 'roundrect_top_dashed' => '┄',
49
+ 'roundrect_top_left' => '╭',
50
+ 'roundrect_top_right' => '╮',
51
+ 'separator' => '─',
52
+ 'tee_left' => '┤',
53
+ 'tee_right' => '├'
54
+ }.freeze
55
+
56
+ PARTS_ASCII = {
57
+ 'cross_diag' => 'X',
58
+ 'corner_bot_left' => '\\',
59
+ 'corner_bot_right' => '/',
60
+ 'corner_top_left' => '/',
61
+ 'corner_top_right' => '\\',
62
+ 'cross' => '+',
63
+ 'left' => '|',
64
+ 'line' => '-',
65
+ 'line_vertical' => '|',
66
+ 'multi_repeat' => '&',
67
+ 'rect_bot' => '-',
68
+ 'rect_bot_dashed' => '-',
69
+ 'rect_bot_left' => '+',
70
+ 'rect_bot_right' => '+',
71
+ 'rect_left' => '|',
72
+ 'rect_left_dashed' => '|',
73
+ 'rect_right' => '|',
74
+ 'rect_right_dashed' => '|',
75
+ 'rect_top' => '-',
76
+ 'rect_top_dashed' => '-',
77
+ 'rect_top_left' => '+',
78
+ 'rect_top_right' => '+',
79
+ 'repeat_bot_left' => '\\',
80
+ 'repeat_bot_right' => '/',
81
+ 'repeat_left' => '|',
82
+ 'repeat_right' => '|',
83
+ 'repeat_top_left' => '/',
84
+ 'repeat_top_right' => '\\',
85
+ 'right' => '|',
86
+ 'roundcorner_bot_left' => '\\',
87
+ 'roundcorner_bot_right' => '/',
88
+ 'roundcorner_top_left' => '/',
89
+ 'roundcorner_top_right' => '\\',
90
+ 'roundrect_bot' => '-',
91
+ 'roundrect_bot_dashed' => '-',
92
+ 'roundrect_bot_left' => '\\',
93
+ 'roundrect_bot_right' => '/',
94
+ 'roundrect_left' => '|',
95
+ 'roundrect_left_dashed' => '|',
96
+ 'roundrect_right' => '|',
97
+ 'roundrect_right_dashed' => '|',
98
+ 'roundrect_top' => '-',
99
+ 'roundrect_top_dashed' => '-',
100
+ 'roundrect_top_left' => '/',
101
+ 'roundrect_top_right' => '\\',
102
+ 'separator' => '-',
103
+ 'tee_left' => '|',
104
+ 'tee_right' => '|'
105
+ }.freeze
106
+
107
+ class << self
108
+ attr_accessor :parts
109
+
110
+ def set_formatting(characters = nil, defaults = nil)
111
+ return unless characters
112
+
113
+ @parts = defaults ? defaults.dup : {}
114
+ @parts.merge!(characters)
115
+ @parts.each do |name, value|
116
+ raise ArgumentError, "Text part #{name} is more than 1 character: #{value}" if value.size != 1
117
+ end
118
+ end
119
+
120
+ def rect(item, dashed = false)
121
+ rectish('rect', item, dashed)
122
+ end
123
+
124
+ def round_rect(item, dashed = false)
125
+ rectish('roundrect', item, dashed)
126
+ end
127
+
128
+ def pad_l(string, width, pad)
129
+ gap = width - string.length
130
+ raise "Gap #{gap} must be a multiple of pad string '#{pad}'" unless gap % pad.length == 0
131
+
132
+ (pad * (gap / pad.length)) + string
133
+ end
134
+
135
+ def pad_r(string, width, pad)
136
+ gap = width - string.length
137
+ raise "Gap #{gap} must be a multiple of pad string '#{pad}'" unless gap % pad.length == 0
138
+
139
+ string + (pad * (gap / pad.length))
140
+ end
141
+
142
+ private
143
+
144
+ def rectish(rect_type, data, dashed)
145
+ line_type = dashed ? '_dashed' : ''
146
+ parts = get_parts([
147
+ "#{rect_type}_top_left",
148
+ "#{rect_type}_left#{line_type}",
149
+ "#{rect_type}_bot_left",
150
+ "#{rect_type}_top_right",
151
+ "#{rect_type}_right#{line_type}",
152
+ "#{rect_type}_bot_right",
153
+ "#{rect_type}_top#{line_type}",
154
+ "#{rect_type}_bot#{line_type}",
155
+ 'line',
156
+ 'cross'
157
+ ])
158
+
159
+ item_td = data.is_a?(TextDiagram) ? data : new(0, 0, [data.to_s])
160
+
161
+ lines = [parts[6] * (item_td.width + 2)]
162
+ lines += item_td.expand(1, 1, 0, 0).lines.map { |line| " #{line} " }
163
+ lines << (parts[7] * (item_td.width + 2))
164
+
165
+ entry = item_td.entry + 1
166
+ exit = item_td.exit + 1
167
+
168
+ left_max = [parts[0], parts[1], parts[2]].map(&:size).max
169
+ lefts = Array.new(lines.size, parts[1].ljust(left_max))
170
+ lefts[0] = parts[0].ljust(left_max, parts[6])
171
+ lefts[-1] = parts[2].ljust(left_max, parts[7])
172
+ lefts[entry] = parts[9].ljust(left_max) if data.is_a?(TextDiagram)
173
+
174
+ right_max = [parts[3], parts[4], parts[5]].map(&:size).max
175
+ rights = Array.new(lines.size, parts[4].rjust(right_max))
176
+ rights[0] = parts[3].rjust(right_max, parts[6])
177
+ rights[-1] = parts[5].rjust(right_max, parts[7])
178
+ rights[exit] = parts[9].rjust(right_max) if data.is_a?(TextDiagram)
179
+
180
+ new_lines = lines.each_with_index.map do |line, i|
181
+ lefts[i] + line + rights[i]
182
+ end
183
+
184
+ lefts = Array.new(lines.size, ' ')
185
+ lefts[entry] = parts[8]
186
+ rights = Array.new(lines.size, ' ')
187
+ rights[exit] = parts[8]
188
+
189
+ new_lines = new_lines.each_with_index.map do |line, i|
190
+ lefts[i] + line + rights[i]
191
+ end
192
+
193
+ new(entry, exit, new_lines)
194
+ end
195
+
196
+ def enclose_lines(lines, lefts, rights)
197
+ unless lines.length == lefts.length && lines.length == rights.length
198
+ raise 'All arguments must be the same length'
199
+ end
200
+
201
+ lines.each_with_index.map { |line, i| lefts[i] + line + rights[i] }
202
+ end
203
+
204
+ def gaps(outer_width, inner_width)
205
+ diff = outer_width - inner_width
206
+ case INTERNAL_ALIGNMENT
207
+ when 'left'
208
+ [0, diff]
209
+ when 'right'
210
+ [diff, 0]
211
+ else
212
+ left = diff / 2
213
+ right = diff - left
214
+ [left, right]
215
+ end
216
+ end
217
+
218
+ def get_parts(part_names)
219
+ part_names.map { |name| @parts[name] }
220
+ end
221
+ end
222
+
223
+ attr_reader :entry, :exit, :height, :lines, :width
224
+
225
+ def initialize(entry, exit, lines)
226
+ @entry = entry
227
+ @exit = exit
228
+ @lines = lines.dup
229
+ @height = lines.size
230
+ @width = lines.empty? ? 0 : lines.first.size
231
+
232
+ validate
233
+ end
234
+
235
+ def alter(new_entry = nil, new_exit = nil, new_lines = nil)
236
+ self.class.new(
237
+ new_entry || @entry,
238
+ new_exit || @exit,
239
+ new_lines || @lines.dup
240
+ )
241
+ end
242
+
243
+ def append_below(item, lines_between, move_entry: false, move_exit: false)
244
+ new_width = [@width, item.width].max
245
+ new_lines = center(new_width).lines
246
+ lines_between.each { |line| new_lines << TextDiagram.pad_r(line, new_width, ' ') }
247
+ new_lines += item.center(new_width).lines
248
+
249
+ new_entry = move_entry ? @height + lines_between.size + item.entry : @entry
250
+ new_exit = move_exit ? @height + lines_between.size + item.exit : @exit
251
+
252
+ self.class.new(new_entry, new_exit, new_lines)
253
+ end
254
+
255
+ def append_right(item, chars_between)
256
+ join_line = [@exit, item.entry].max
257
+ new_height = [@height - @exit, item.height - item.entry].max + join_line
258
+
259
+ left = expand(0, 0, join_line - @exit, new_height - @height - (join_line - @exit))
260
+ right = item.expand(0, 0, join_line - item.entry, new_height - item.height - (join_line - item.entry))
261
+
262
+ new_lines = (0...new_height).map do |i|
263
+ sep = i == join_line ? chars_between : ' ' * chars_between.size
264
+ left_line = i < left.lines.size ? left.lines[i] : ' ' * left.width
265
+ right_line = i < right.lines.size ? right.lines[i] : ' ' * right.width
266
+ "#{left_line}#{sep}#{right_line}"
267
+ end
268
+
269
+ self.class.new(
270
+ @entry + (join_line - @exit),
271
+ item.exit + (join_line - item.entry),
272
+ new_lines
273
+ )
274
+ end
275
+
276
+ def center(new_width, pad = ' ')
277
+ raise 'Cannot center into smaller width' if width < @width
278
+ return copy if new_width == @width
279
+
280
+ total_padding = new_width - @width
281
+ left_width = total_padding / 2
282
+ left = [pad * left_width] * @height
283
+ right = [pad * (total_padding - left_width)] * @height
284
+
285
+ self.class.new(@entry, @exit, enclose_lines(@lines, left, right))
286
+ end
287
+
288
+ def copy
289
+ self.class.new(@entry, @exit, @lines.dup)
290
+ end
291
+
292
+ def expand(left, right, top, bottom)
293
+ return copy if [left, right, top, bottom].all?(&:zero?)
294
+
295
+ new_lines = []
296
+ top.times { new_lines << (' ' * (@width + left + right)) }
297
+
298
+ @lines.each do |line|
299
+ left_part = (line == @lines[@entry] ? self.class.parts['line'] : ' ') * left
300
+ right_part = (line == @lines[@exit] ? self.class.parts['line'] : ' ') * right
301
+ new_lines << "#{left_part}#{line}#{right_part}"
302
+ end
303
+
304
+ bottom.times { new_lines << (' ' * (@width + left + right)) }
305
+
306
+ self.class.new(
307
+ @entry + top,
308
+ @exit + top,
309
+ new_lines
310
+ )
311
+ end
312
+
313
+ private
314
+
315
+ def validate
316
+ return if @lines.empty?
317
+
318
+ line_length = @lines.first.size
319
+ @lines.each do |line|
320
+ raise ArgumentError, "Diagram is not rectangular:\n#{inspect}" unless line.size == line_length
321
+ end
322
+
323
+ raise ArgumentError, "Entry point out of bounds:\n#{inspect}" if @entry >= @height
324
+
325
+ return unless @exit >= @height
326
+
327
+ raise ArgumentError, "Exit point out of bounds:\n#{inspect}"
328
+ end
329
+
330
+ def inspect
331
+ output = ["TextDiagram(entry=#{@entry}, exit=#{@exit}, height=#{@height})"]
332
+ @lines.each_with_index do |line, i|
333
+ marker = []
334
+ marker << 'entry' if i == @entry
335
+ marker << 'exit' if i == @exit
336
+ output << (format('%3d: %-20s %s', i, line.inspect, marker.join(', ')))
337
+ end
338
+ output.join("\n")
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class ZeroOrMore
5
+ def self.new(item, repeat = nil, skip = false)
6
+ Optional.new(OneOrMore.new(item, repeat), skip)
7
+ end
8
+ end
9
+ end