railroad_diagrams 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.
@@ -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