elkrb 1.0.0 → 2.0.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "token"
5
+
6
+ module Elkrb
7
+ module Parsers
8
+ module Elkt
9
+ # Turns ELKT source into a flat Array of Token, ending in :eof.
10
+ #
11
+ # Scan order is load-bearing. Strings are consumed ATOMICALLY before any
12
+ # comment is recognised, so `/*` and `//` inside a label are ordinary
13
+ # characters; the reverse order deletes everything between a label's `/*`
14
+ # and the next real block comment.
15
+ #
16
+ # @api private
17
+ class Lexer
18
+ BOM = "\uFEFF"
19
+ WS = /\G[ \t\r\n]+/
20
+ NEWLINE = /\r\n|\r|\n/
21
+ # The exponent alternative MUST precede the plain-decimal one: Ruby
22
+ # alternation is ordered, so `\d+\.\d+` first matches "1.5" of
23
+ # "1.5e-3" and leaves "e-3" to lex as an identifier.
24
+ NUMBER = /\G[+-]?(?:\d+(?:\.\d+)?[eE][+-]?\d+|\d+\.\d+|\d+)/
25
+ IDENT = /\G\^?[A-Za-z_]\w*(?:\.\^?[A-Za-z_]\w*)*/
26
+ PUNCT = {
27
+ "{" => :lbrace, "}" => :rbrace, "[" => :lbracket,
28
+ "]" => :rbracket, ":" => :colon, "," => :comma, "." => :dot
29
+ }.freeze
30
+
31
+ def initialize(source)
32
+ unless source.is_a?(String)
33
+ raise TypeError,
34
+ "ELKT source must be a String, got #{source.class}"
35
+ end
36
+
37
+ @src = normalize(source)
38
+ @pos = 0
39
+ @line = 1
40
+ @col = 1
41
+ @tokens = []
42
+ @scanners = build_scanners
43
+ end
44
+
45
+ def tokenize
46
+ @pos += BOM.length if @src.start_with?(BOM)
47
+ scan_one while @pos < @src.length
48
+ emit(:eof, "", nil)
49
+ @tokens
50
+ end
51
+
52
+ private
53
+
54
+ # The scan order described above, written out. Keep the string
55
+ # scanner before the two comment scanners.
56
+ def build_scanners
57
+ [
58
+ method(:take_whitespace), method(:take_string),
59
+ method(:take_line_comment), method(:take_block_comment),
60
+ method(:take_arrow), method(:take_number),
61
+ method(:take_identifier), method(:take_pipe),
62
+ method(:take_punctuation)
63
+ ].freeze
64
+ end
65
+
66
+ # Skipped by moving @pos alone: a byte-order mark is a zero-width
67
+ # marker, so the first real character is still at column 1. Routing it
68
+ # through `advance` shifted every first-line column by one.
69
+ #
70
+ # UTF-8 and ASCII-8BIT carry no real source encoding to transcode
71
+ # FROM -- UTF-8 is already the target, and ASCII-8BIT is Ruby's "no
72
+ # encoding is known" tag -- so both are reinterpreted in place.
73
+ # Anything else is a real, named encoding (e.g. a file read with
74
+ # `encoding: "ISO-8859-1"`) and is converted with String#encode
75
+ # instead, which is what turns a Latin-1 0xE9 into the accented
76
+ # character it actually names rather than a byte sequence that
77
+ # merely happens to be invalid UTF-8.
78
+ NO_SOURCE_ENCODING_TO_TRANSCODE_FROM =
79
+ [Encoding::UTF_8, Encoding::ASCII_8BIT].freeze
80
+ private_constant :NO_SOURCE_ENCODING_TO_TRANSCODE_FROM
81
+
82
+ def normalize(text)
83
+ if NO_SOURCE_ENCODING_TO_TRANSCODE_FROM.include?(text.encoding)
84
+ reinterpret(text)
85
+ else
86
+ transcode(text)
87
+ end
88
+ end
89
+
90
+ # @return [String] transcoded to UTF-8, or reinterpreted as UTF-8
91
+ # when the source is invalid in its own declared encoding.
92
+ def transcode(text)
93
+ text.encode(Encoding::UTF_8)
94
+ rescue EncodingError
95
+ reinterpret(text)
96
+ end
97
+
98
+ # @return [String] the same bytes, tagged UTF-8.
99
+ # @raise [Elkrb::ParseError] when those bytes are not valid UTF-8.
100
+ def reinterpret(text)
101
+ text = text.dup.force_encoding(Encoding::UTF_8)
102
+ return text if text.valid_encoding?
103
+
104
+ line, col = first_invalid_location(text)
105
+ raise Elkrb::ParseError.new(
106
+ "Input is not valid UTF-8 at line #{line}, column #{col}",
107
+ line: line, column: col,
108
+ )
109
+ end
110
+
111
+ # Walks up to the first broken character and counts the lines before
112
+ # it. Reporting line 1, column 1 for every file pointed the user at
113
+ # the top even when the bad bytes were far down.
114
+ def first_invalid_location(text)
115
+ line = 1
116
+ col = 1
117
+ previous = nil
118
+ text.each_char do |char|
119
+ return [line, col] unless char.valid_encoding?
120
+
121
+ line, col = step_location(char, previous, line, col)
122
+ previous = char
123
+ end
124
+ [line, col]
125
+ end
126
+
127
+ # CRLF is one line break, so the LF after a CR must not count again.
128
+ def step_location(char, previous, line, col)
129
+ return [line + 1, 1] if char == "\r" ||
130
+ (char == "\n" && previous != "\r")
131
+ return [line, col] if char == "\n"
132
+
133
+ [line, col + 1]
134
+ end
135
+
136
+ def scan_one
137
+ return if @scanners.any?(&:call)
138
+
139
+ raise_at(@line, @col,
140
+ "Unexpected character #{@src[@pos].inspect}")
141
+ end
142
+
143
+ def take_whitespace
144
+ text = match(WS) or return nil
145
+
146
+ advance(text)
147
+ text
148
+ end
149
+
150
+ # Xtext's SL_COMMENT is `'//' !('\n'|'\r')*`, so a lone CR ends it too.
151
+ # Stopping only at LF swallowed the rest of a CR-delimited file.
152
+ def take_line_comment
153
+ return unless @src[@pos, 2] == "//"
154
+
155
+ stop = @src.index(/[\r\n]/, @pos)
156
+ text = stop ? @src[@pos...stop] : @src[@pos..]
157
+ advance(text)
158
+ text
159
+ end
160
+
161
+ def take_block_comment
162
+ return unless @src[@pos, 2] == "/*"
163
+
164
+ line = @line
165
+ col = @col
166
+ close = @src.index("*/", @pos + 2)
167
+ raise_at(line, col, "Unterminated block comment") unless close
168
+
169
+ text = @src[@pos...(close + 2)]
170
+ advance(text)
171
+ text
172
+ end
173
+
174
+ def take_arrow
175
+ return unless @src[@pos, 2] == "->"
176
+
177
+ emit(:arrow, "->", nil)
178
+ end
179
+
180
+ def take_number
181
+ text = match(NUMBER) or return nil
182
+
183
+ emit(:number, text, cast_number(text))
184
+ end
185
+
186
+ def take_identifier
187
+ text = match(IDENT) or return nil
188
+
189
+ segments = build_segments(text)
190
+ emit(:identifier, text, segments.map(&:name).join("."), segments)
191
+ end
192
+
193
+ def take_pipe
194
+ return unless @src[@pos] == "|"
195
+
196
+ emit(:pipe, "|", "|")
197
+ end
198
+
199
+ def take_punctuation
200
+ type = PUNCT[@src[@pos]] or return nil
201
+
202
+ emit(type, @src[@pos], @src[@pos])
203
+ end
204
+
205
+ # Consumes the whole literal and keeps the RAW inner lexeme: label text
206
+ # and property values decode differently upstream, so decoding belongs
207
+ # at the parse site.
208
+ def take_string
209
+ quote = @src[@pos]
210
+ return unless ['"', "'"].include?(quote)
211
+
212
+ line = @line
213
+ col = @col
214
+ inner = read_string_body(quote, line, col)
215
+ @tokens << Token.new(type: :string, value: inner, segments: nil,
216
+ line: line, column: col)
217
+ inner
218
+ end
219
+
220
+ def read_string_body(quote, line, col)
221
+ advance(quote)
222
+ inner = +""
223
+ inner << take_string_char while @pos < @src.length &&
224
+ @src[@pos] != quote
225
+ raise_at(line, col, "Unterminated string") if @pos >= @src.length
226
+
227
+ advance(quote)
228
+ inner
229
+ end
230
+
231
+ # Raw newlines are legal inside an Xtext STRING, and this walks the
232
+ # body one character at a time -- so CRLF has to be consumed as a pair
233
+ # here too, or the two halves reach the counter separately and every
234
+ # later location is a line out.
235
+ def take_string_char
236
+ return advance_with("\r\n") if crlf?(@pos)
237
+ return take_escape if @src[@pos] == "\\"
238
+
239
+ advance_with(@src[@pos])
240
+ end
241
+
242
+ # The escape branch consumed the backslash and the CR together, which
243
+ # left the LF for the next advance and counted the pair as two lines.
244
+ def take_escape
245
+ nxt = @src[@pos + 1]
246
+ raise_at(@line, @col, "Unterminated escape") if nxt.nil?
247
+ return advance_with("\\\r\n") if crlf?(@pos + 1)
248
+
249
+ advance_with("\\#{nxt}")
250
+ end
251
+
252
+ def crlf?(pos)
253
+ @src[pos] == "\r" && @src[pos + 1] == "\n"
254
+ end
255
+
256
+ def advance_with(text)
257
+ advance(text)
258
+ text
259
+ end
260
+
261
+ def build_segments(text)
262
+ text.split(".").map do |segment|
263
+ if segment.start_with?("^")
264
+ Segment.new(name: segment[1..], escaped: true)
265
+ else
266
+ Segment.new(name: segment, escaped: false)
267
+ end
268
+ end
269
+ end
270
+
271
+ def cast_number(text)
272
+ text.match?(/[.eE]/) ? text.to_f : text.to_i
273
+ end
274
+
275
+ def match(regexp)
276
+ regexp.match(@src, @pos)&.[](0)
277
+ end
278
+
279
+ def emit(type, text, value, segments = nil)
280
+ @tokens << Token.new(type: type, value: value, segments: segments,
281
+ line: @line, column: @col)
282
+ advance(text)
283
+ @tokens.last
284
+ end
285
+
286
+ # CRLF, lone CR and lone LF each end one line. Counting only LF left
287
+ # every location wrong in the CR-delimited files the comment fix
288
+ # started accepting.
289
+ def advance(text)
290
+ parts = text.split(NEWLINE, -1)
291
+ if parts.length <= 1
292
+ @col += text.length
293
+ else
294
+ @line += parts.length - 1
295
+ @col = parts.last.length + 1
296
+ end
297
+ @pos += text.length
298
+ end
299
+
300
+ def raise_at(line, col, message)
301
+ raise Elkrb::ParseError.new(
302
+ "#{message} at line #{line}, column #{col}",
303
+ line: line, column: col,
304
+ )
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end