elkrb 1.0.2 → 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
@@ -3,6 +3,9 @@
3
3
  require "json"
4
4
  require "yaml"
5
5
 
6
+ require_relative "../errors"
7
+ require_relative "../best_effort_write"
8
+
6
9
  module Elkrb
7
10
  module Commands
8
11
  # Command for validating ELK graph structure
@@ -19,68 +22,74 @@ module Elkrb
19
22
  errors = validate_graph(graph)
20
23
 
21
24
  if errors.empty?
22
- puts "✅ #{@file} is valid"
25
+ marker = stream_marker($stdout, "✅", "[OK]")
26
+ puts "#{marker} #{@file} is valid"
23
27
  else
24
- puts "❌ #{@file} has #{errors.length} error(s):"
25
- errors.each { |e| puts " • #{e}" }
26
- exit 1
28
+ summary = "#{@file} has #{errors.length} error(s)"
29
+ marker = stream_marker($stderr, "❌", "[ERROR]")
30
+ bullet = stream_marker($stderr, "•", "-")
31
+ BestEffortWrite.attempt do
32
+ $stderr.puts "#{marker} #{summary}:" # rubocop:disable Style/StderrPuts
33
+ errors.each do |error|
34
+ $stderr.puts " #{bullet} #{error}" # rubocop:disable Style/StderrPuts
35
+ end
36
+ end
37
+ raise Elkrb::CommandFailed, summary
27
38
  end
28
39
  end
29
40
 
30
41
  private
31
42
 
43
+ def stream_marker(stream, unicode, ascii)
44
+ encoding = stream.external_encoding
45
+ return unicode unless encoding
46
+
47
+ unicode.encode(encoding)
48
+ unicode
49
+ rescue EncodingError
50
+ ascii
51
+ end
52
+
32
53
  def load_any_format(file)
33
54
  raise ArgumentError, "File not found: #{file}" unless File.exist?(file)
34
55
 
35
- require_relative "../graph/graph"
36
56
  content = File.read(file)
37
- ext = File.extname(file).downcase
38
-
39
- graph = case ext
40
- when ".json"
41
- Elkrb::Graph::Graph.from_json(content)
42
- when ".yml", ".yaml"
43
- Elkrb::Graph::Graph.from_yaml(content)
44
- when ".elkt"
45
- require_relative "../parsers/elkt_parser"
46
- Elkrb::Parsers::ElktParser.parse(content)
47
- else
48
- detect_and_parse(content)
49
- end
50
-
51
- # Convert to hash for validation
52
- if graph.is_a?(Hash)
53
- graph
54
- else
55
- JSON.parse(graph.to_json,
56
- symbolize_names: true)
57
+
58
+ case File.extname(file).downcase
59
+ when ".json" then parse_json(content)
60
+ when ".yml", ".yaml" then YAML.safe_load(content)
61
+ when ".elkt" then parse_elkt(content)
62
+ else sniff_format(content)
57
63
  end
58
64
  end
59
65
 
60
- def detect_and_parse(content)
61
- require_relative "../graph/graph"
66
+ def parse_json(content)
67
+ JSON.parse(content, symbolize_names: true)
68
+ end
62
69
 
63
- # Try JSON first
64
- begin
65
- return Elkrb::Graph::Graph.from_json(content)
66
- rescue JSON::ParserError
67
- # Not JSON
68
- end
70
+ def parse_elkt(content)
71
+ require_relative "../parsers/elkt_parser"
72
+ Elkrb::Parsers::ElktParser.parse(content)
73
+ end
69
74
 
70
- # Try YAML
71
- begin
72
- return Elkrb::Graph::Graph.from_yaml(content)
73
- rescue Psych::SyntaxError
74
- # Not YAML
75
- end
75
+ def sniff_format(content)
76
+ stripped = content.lstrip
76
77
 
77
- # Try ELKT
78
- begin
79
- require_relative "../parsers/elkt_parser"
80
- Elkrb::Parsers::ElktParser.parse(content)
81
- rescue StandardError
82
- raise ArgumentError, "Unable to parse input file"
78
+ if stripped.start_with?("{", "[")
79
+ begin
80
+ return parse_json(content)
81
+ rescue JSON::ParserError
82
+ nil
83
+ end
83
84
  end
85
+
86
+ yaml = YAML.safe_load(content)
87
+ return yaml if yaml.is_a?(Hash) || yaml.is_a?(Array)
88
+
89
+ parse_elkt(content)
90
+ rescue Psych::SyntaxError, Elkrb::ParseError
91
+ raise ArgumentError,
92
+ "Unable to parse input file. Supported formats: JSON, YAML, ELKT"
84
93
  end
85
94
 
86
95
  def validate_graph(graph)
@@ -93,24 +102,25 @@ module Elkrb
93
102
  # Check required fields
94
103
  errors << "Graph missing 'id' field" unless graph[:id] || graph["id"]
95
104
 
96
- # Validate children (nodes)
97
- children = graph[:children] || graph["children"] || []
105
+ errors.concat(validate_container(graph))
106
+
107
+ errors
108
+ end
109
+
110
+ def validate_container(container, path = nil)
111
+ errors = []
112
+ children = collection(container, :children, path, errors)
113
+ edges = collection(container, :edges, path, errors)
114
+ endpoint_ids = descendant_endpoint_ids(children)
115
+
116
+ errors.concat(duplicate_id_errors(children, edges))
98
117
  children.each_with_index do |node, idx|
99
- errors.concat(validate_node(node, "children[#{idx}]"))
118
+ errors.concat(validate_node(node, child_path(path, idx)))
100
119
  end
101
-
102
- # Validate edges
103
- edges = graph[:edges] || graph["edges"] || []
104
120
  edges.each_with_index do |edge, idx|
105
- errors.concat(validate_edge(edge, "edges[#{idx}]",
106
- collect_node_ids(graph)))
107
- end
108
-
109
- # Strict mode: additional checks
110
- if @options[:strict]
111
- errors.concat(validate_strict(graph))
121
+ errors.concat(validate_edge(edge, edge_path(path, idx), endpoint_ids))
112
122
  end
113
-
123
+ errors.concat(validate_strict(container, path)) if @options[:strict]
114
124
  errors
115
125
  end
116
126
 
@@ -132,31 +142,26 @@ module Elkrb
132
142
  errors << "#{path}: Node '#{node_id}' missing 'width'" unless width
133
143
  errors << "#{path}: Node '#{node_id}' missing 'height'" unless height
134
144
 
135
- if width && (!width.is_a?(Numeric) || width <= 0)
145
+ if width && !positive_finite_number?(width)
136
146
  errors << "#{path}: Node '#{node_id}' has invalid width: #{width}"
137
147
  end
138
148
 
139
- if height && (!height.is_a?(Numeric) || height <= 0)
149
+ if height && !positive_finite_number?(height)
140
150
  errors << "#{path}: Node '#{node_id}' has invalid height: #{height}"
141
151
  end
142
152
  end
143
153
 
144
- # Validate nested children
145
- children = node[:children] || node["children"] || []
146
- children.each_with_index do |child, idx|
147
- errors.concat(validate_node(child, "#{path}.children[#{idx}]"))
148
- end
149
-
150
154
  # Validate ports
151
- ports = node[:ports] || node["ports"] || []
155
+ ports = collection(node, :ports, path, errors)
152
156
  ports.each_with_index do |port, idx|
153
157
  errors.concat(validate_port(port, "#{path}.ports[#{idx}]"))
154
158
  end
155
159
 
160
+ errors.concat(validate_container(node, path))
156
161
  errors
157
162
  end
158
163
 
159
- def validate_edge(edge, path, valid_node_ids)
164
+ def validate_edge(edge, path, valid_ids)
160
165
  errors = []
161
166
 
162
167
  errors << "#{path}: Edge must be a Hash" unless edge.is_a?(Hash)
@@ -180,17 +185,17 @@ module Elkrb
180
185
  errors << "#{path}: Edge '#{edge_id}' targets must be an array"
181
186
  end
182
187
 
183
- # Check that sources and targets reference valid nodes (in strict mode)
184
- if @options[:strict] && sources.is_a?(Array) && targets.is_a?(Array)
185
- sources.each do |source|
186
- unless valid_node_ids.include?(source)
187
- errors << "#{path}: Edge '#{edge_id}' references unknown source node '#{source}'"
188
- end
189
- end
190
-
191
- targets.each do |target|
192
- unless valid_node_ids.include?(target)
193
- errors << "#{path}: Edge '#{edge_id}' references unknown target node '#{target}'"
188
+ { source: sources, target: targets }.each do |role, endpoints|
189
+ next unless endpoints.is_a?(Array)
190
+
191
+ endpoints.each do |endpoint|
192
+ matches = valid_ids.fetch(endpoint, 0)
193
+ if matches.zero?
194
+ errors << "#{path}: Edge '#{edge_id}' references unknown " \
195
+ "#{role} node or port '#{endpoint}'"
196
+ elsif matches > 1
197
+ errors << "#{path}: Edge '#{edge_id}' references ambiguous " \
198
+ "#{role} node or port '#{endpoint}'"
194
199
  end
195
200
  end
196
201
  end
@@ -211,31 +216,81 @@ module Elkrb
211
216
  errors
212
217
  end
213
218
 
214
- def validate_strict(graph)
219
+ def validate_strict(graph, path)
215
220
  errors = []
216
221
 
217
222
  # Check for layout options
218
- layout_options = graph[:layoutOptions] || graph["layoutOptions"]
219
- if layout_options && !layout_options.is_a?(Hash)
220
- errors << "layoutOptions must be a Hash"
223
+ layout_options = value(graph, :layoutOptions)
224
+ if !layout_options.nil? && !layout_options.is_a?(Hash)
225
+ prefix = path ? "#{path}." : ""
226
+ errors << "#{prefix}layoutOptions must be a Hash"
221
227
  end
222
228
 
223
229
  errors
224
230
  end
225
231
 
226
- def collect_node_ids(graph, ids = [])
227
- children = graph[:children] || graph["children"] || []
232
+ def collection(container, key, path, errors)
233
+ items = value(container, key)
234
+ return [] if items.nil?
235
+ return items if items.is_a?(Array)
236
+
237
+ prefix = path ? "#{path}." : ""
238
+ errors << "#{prefix}#{key} must be an Array"
239
+ []
240
+ end
241
+
242
+ def value(hash, key)
243
+ return hash[key] if hash.key?(key)
244
+
245
+ hash[key.to_s]
246
+ end
247
+
248
+ def child_path(path, index)
249
+ [path, "children[#{index}]"].compact.join(".")
250
+ end
251
+
252
+ def edge_path(path, index)
253
+ [path, "edges[#{index}]"].compact.join(".")
254
+ end
255
+
256
+ def positive_finite_number?(number)
257
+ number.is_a?(Numeric) && number.finite? && number.positive?
258
+ end
228
259
 
260
+ def descendant_endpoint_ids(children, ids = Hash.new(0))
229
261
  children.each do |node|
230
- node_id = node[:id] || node["id"]
231
- ids << node_id if node_id
262
+ next unless node.is_a?(Hash)
232
263
 
233
- # Recursively collect from nested children
234
- collect_node_ids(node, ids)
264
+ ids[value(node, :id)] += 1 if value(node, :id)
265
+ Array(value(node, :ports)).each do |port|
266
+ id = value(port, :id) if port.is_a?(Hash)
267
+ ids[id] += 1 if id
268
+ end
269
+ descendant_endpoint_ids(Array(value(node, :children)), ids)
235
270
  end
236
-
237
271
  ids
238
272
  end
273
+
274
+ def duplicate_id_errors(children, edges)
275
+ ids = Set.new
276
+ errors = []
277
+ children.each do |node|
278
+ next unless node.is_a?(Hash)
279
+
280
+ record_id(value(node, :id), ids, errors)
281
+ Array(value(node, :ports)).each do |port|
282
+ record_id(value(port, :id), ids, errors) if port.is_a?(Hash)
283
+ end
284
+ end
285
+ edges.each do |edge|
286
+ record_id(value(edge, :id), ids, errors) if edge.is_a?(Hash)
287
+ end
288
+ errors
289
+ end
290
+
291
+ def record_id(id, ids, errors)
292
+ errors << "duplicate id: #{id}" if id && !ids.add?(id)
293
+ end
239
294
  end
240
295
  end
241
296
  end
data/lib/elkrb/errors.rb CHANGED
@@ -18,13 +18,32 @@ module Elkrb
18
18
  # Exception raised when graph validation fails
19
19
  class ValidationError < Error; end
20
20
 
21
+ # Exception raised when input cannot be parsed. Carries the source position
22
+ # so a caller can report it without re-parsing the message.
23
+ class ParseError < Error
24
+ attr_reader :line, :column
25
+
26
+ def initialize(message, line: nil, column: nil)
27
+ @line = line
28
+ @column = column
29
+ super(message)
30
+ end
31
+ end
32
+
21
33
  # Exception raised when an algorithm is not found
22
34
  class AlgorithmNotFoundError < Error
23
35
  attr_reader :algorithm_name
24
36
 
25
37
  def initialize(algorithm_name)
26
38
  @algorithm_name = algorithm_name
27
- super("Algorithm not found: #{algorithm_name}")
39
+ super("Unknown layout algorithm: #{algorithm_name}")
28
40
  end
29
41
  end
42
+
43
+ # Raised when a command has already ATTEMPTED to report its own failure and
44
+ # the only thing left to decide is the exit status. Reporting is best-effort:
45
+ # a dead stdout leaves nothing printed and this is still raised, so do not
46
+ # assume the user saw anything. `exe/elkrb` turns it into `exit 1`; a library
47
+ # caller rescues it and keeps its process.
48
+ class CommandFailed < Error; end
30
49
  end