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
@@ -1,235 +1,466 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../errors"
4
5
 
5
6
  module Elkrb
6
7
  module Serializers
7
- # Serializer for ELKT (ELK Text) format
8
- # Converts ELK graph structures to textual ELKT representation
8
+ # Serializes graph models and Hashes to ELK Text.
9
9
  class ElktSerializer
10
+ IDENTIFIER = /\A[A-Za-z_]\w*\z/
11
+ PROPERTY_SEGMENT = /\A[A-Za-z_]\w*\z/
12
+ KEYWORDS = %w[
13
+ graph node port label edge layout section position size
14
+ start end bends incoming outgoing true false null
15
+ ].freeze
16
+ LABEL_ESCAPES = {
17
+ "\\" => "\\\\", '"' => '\\"', "\n" => "\\n", "\r" => "\\r",
18
+ "\t" => "\\t", "\b" => "\\b", "\f" => "\\f"
19
+ }.freeze
20
+ private_constant :IDENTIFIER, :PROPERTY_SEGMENT, :KEYWORDS, :LABEL_ESCAPES
21
+
10
22
  def initialize(options = {})
11
23
  @indent_size = options[:indent_size] || 2
12
- @include_comments = options.fetch(:include_comments, true)
13
24
  end
14
25
 
15
26
  def serialize(graph, _options = {})
16
27
  @indent_level = 0
17
28
  @output = []
29
+ @graph_hash = graph_hash(graph)
30
+ @scope_stack = []
31
+ prepare_ids(@graph_hash)
32
+ emit_root(@graph_hash)
33
+ "#{@output.join("\n")}\n"
34
+ end
18
35
 
19
- # Convert to hash using JSON round-trip for Lutaml models
20
- @graph_hash = if graph.is_a?(Hash)
21
- graph
22
- elsif graph.respond_to?(:to_json)
23
- JSON.parse(graph.to_json, symbolize_names: true)
24
- else
25
- graph
26
- end
36
+ private
27
37
 
28
- serialize_graph(@graph_hash)
38
+ def graph_hash(graph)
39
+ return graph if graph.is_a?(Hash)
40
+ return JSON.parse(graph.to_json, symbolize_names: true) if
41
+ graph.respond_to?(:to_json)
29
42
 
30
- "#{@output.join("\n")}\n"
43
+ graph
31
44
  end
32
45
 
33
- private
46
+ def emit_root(graph)
47
+ id = value(graph, :id)
48
+ @output << "graph #{mapped_id(id)}" if id
49
+ with_scope(graph) do
50
+ serialize_shape_layout(graph)
51
+ serialize_graph(graph)
52
+ end
53
+ end
34
54
 
35
55
  def serialize_graph(graph)
36
- # Serialize graph-level layout options
37
- layout_opts = graph[:layoutOptions] || graph["layoutOptions"] || {}
38
- serialize_layout_options(layout_opts)
56
+ options = value(graph, :layoutOptions) || {}
57
+ serialize_layout_options(options)
58
+ @output << "" if options.any?
59
+
60
+ labels = value(graph, :labels) || []
61
+ ports = value(graph, :ports) || []
62
+ children = value(graph, :children) || []
63
+ edges = value(graph, :edges) || []
64
+ labels.each { |label| serialize_label(label) }
65
+ ports.each { |port| serialize_port(port) }
66
+ children.each { |node| serialize_node(node) }
67
+ @output << "" if children.any? && edges.any?
68
+ edges.each { |edge| serialize_edge(edge) }
69
+ end
39
70
 
40
- # Add blank line after options if present
41
- @output << "" if (graph[:layoutOptions] || {}).any?
71
+ def serialize_layout_options(options)
72
+ indent = indentation
73
+ options.each do |key, option_value|
74
+ @output << "#{indent}#{property_key(key)}: " \
75
+ "#{format_value(option_value)}"
76
+ end
77
+ end
42
78
 
43
- # Serialize nodes
44
- (graph[:children] || []).each do |node|
45
- serialize_node(node)
79
+ def serialize_node(node)
80
+ id = mapped_id(value(node, :id))
81
+ if node_has_block?(node)
82
+ open_block("node #{id}") { serialize_node_block(node) }
83
+ else
84
+ @output << "#{indentation}node #{id}"
46
85
  end
86
+ end
87
+
88
+ def node_has_block?(node)
89
+ shape_layout_parts(node).any? ||
90
+ collection?(node, :labels) || collection?(node, :ports) ||
91
+ collection?(node, :children) || collection?(node, :edges) ||
92
+ hash_present?(node, :layoutOptions)
93
+ end
47
94
 
48
- # Add blank line before edges if both nodes and edges exist
49
- if (graph[:children] || []).any? && (graph[:edges] || []).any?
50
- @output << ""
95
+ def serialize_node_block(node)
96
+ with_scope(node) do
97
+ serialize_shape_layout(node)
98
+ serialize_layout_options(value(node, :layoutOptions) || {})
99
+ (value(node, :labels) || []).each { |label| serialize_label(label) }
100
+ (value(node, :ports) || []).each { |port| serialize_port(port) }
101
+ (value(node, :children) || []).each { |child| serialize_node(child) }
102
+ (value(node, :edges) || []).each { |edge| serialize_edge(edge) }
51
103
  end
104
+ end
52
105
 
53
- # Serialize edges
54
- (graph[:edges] || []).each do |edge|
55
- serialize_edge(edge)
106
+ def serialize_port(port)
107
+ id = mapped_id(value(port, :id))
108
+ if port_has_block?(port)
109
+ open_block("port #{id}") { serialize_port_block(port) }
110
+ else
111
+ @output << "#{indentation}port #{id}"
56
112
  end
57
113
  end
58
114
 
59
- def serialize_layout_options(options)
60
- return if options.empty?
115
+ def port_has_block?(port)
116
+ shape_layout_parts(port).any? || collection?(port, :labels) ||
117
+ hash_present?(port, :layoutOptions)
118
+ end
61
119
 
62
- options.each do |key, value|
63
- # Remove elk. prefix for cleaner output
64
- display_key = key.to_s.start_with?("elk.") ? key.to_s[4..] : key.to_s
120
+ def serialize_port_block(port)
121
+ serialize_shape_layout(port)
122
+ serialize_layout_options(value(port, :layoutOptions) || {})
123
+ (value(port, :labels) || []).each { |label| serialize_label(label) }
124
+ end
65
125
 
66
- # Special handling for algorithm and direction
67
- @output << if display_key == "algorithm"
68
- "algorithm: #{value}"
69
- elsif display_key == "direction"
70
- "direction: #{value}"
71
- else
72
- "#{display_key}: #{format_value(value)}"
73
- end
126
+ def serialize_edge(edge)
127
+ sources = serialized_endpoints(edge, :source)
128
+ targets = serialized_endpoints(edge, :target)
129
+ id = value(edge, :id)
130
+ prefix = id ? "#{mapped_id(id)}: " : ""
131
+ declaration = "edge #{prefix}#{sources.join(', ')} -> " \
132
+ "#{targets.join(', ')}"
133
+
134
+ if edge_has_block?(edge)
135
+ open_block(declaration) { serialize_edge_block(edge) }
136
+ else
137
+ @output << "#{indentation}#{declaration}"
74
138
  end
75
139
  end
76
140
 
77
- def serialize_node(node)
78
- indent = " " * (@indent_level * @indent_size)
141
+ def edge_has_block?(edge)
142
+ hash_present?(edge, :layoutOptions) || collection?(edge, :labels) ||
143
+ collection?(edge, :sections)
144
+ end
79
145
 
80
- # Check if node has attributes to serialize in a block
81
- has_block = node_has_block?(node)
146
+ def serialize_edge_block(edge)
147
+ emit_sections(value(edge, :sections)) if collection?(edge, :sections)
148
+ serialize_layout_options(value(edge, :layoutOptions) || {})
149
+ (value(edge, :labels) || []).each { |label| serialize_label(label) }
150
+ end
82
151
 
83
- if has_block
84
- @output << "#{indent}node #{node[:id]} {"
85
- @indent_level += 1
152
+ def emit_sections(sections)
153
+ @output << "#{indentation}layout ["
154
+ @indent_level += 1
155
+ used_ids = sections.filter_map do |section|
156
+ id = value(section, :id)
157
+ mapped_id(id) if id
158
+ end.to_h { |id| [id, true] }
159
+ sections.each_with_index do |section, index|
160
+ emit_section(section, sections.length, index, used_ids)
161
+ end
162
+ @indent_level -= 1
163
+ @output << "#{indentation}]"
164
+ end
86
165
 
87
- serialize_node_block(node)
166
+ def emit_section(section, count, index, used_ids)
167
+ id = value(section, :id)
168
+ return emit_section_body(section) if count == 1 && !id
169
+
170
+ id ||= available_section_id(index + 1, used_ids)
171
+ used_ids[mapped_id(id)] = true
172
+ outgoing = Array(value(section, :outgoingSections))
173
+ declaration = "section #{mapped_id(id)}"
174
+ if outgoing.any?
175
+ refs = outgoing.map { |ref| mapped_id(ref) }.join(", ")
176
+ declaration = "#{declaration} -> #{refs}"
177
+ end
178
+ @output << "#{indentation}#{declaration} ["
179
+ @indent_level += 1
180
+ emit_section_body(section)
181
+ @indent_level -= 1
182
+ @output << "#{indentation}]"
183
+ end
88
184
 
89
- @indent_level -= 1
90
- @output << "#{indent}}"
91
- else
92
- @output << "#{indent}node #{node[:id]}"
185
+ def emit_section_body(section)
186
+ emit_shape_reference(section, :incomingShape, "incoming")
187
+ emit_shape_reference(section, :outgoingShape, "outgoing")
188
+ emit_section_point(section, :startPoint, "start")
189
+ emit_section_point(section, :endPoint, "end")
190
+ bends = Array(value(section, :bendPoints))
191
+ unless bends.empty?
192
+ points = bends.map { |point| formatted_point(point) }.join(" | ")
193
+ @output << "#{indentation}bends: #{points}"
93
194
  end
195
+ serialize_layout_options(value(section, :layoutOptions) || {})
94
196
  end
95
197
 
96
- def node_has_block?(node)
97
- # Node needs a block if it has:
98
- # - Layout attributes (size, position)
99
- # - Labels
100
- # - Ports
101
- # - Children (nested nodes)
102
- # - Non-default dimensions
198
+ def available_section_id(index, used_ids)
199
+ loop do
200
+ candidate = "_section#{index}"
201
+ return candidate unless used_ids.key?(mapped_id(candidate))
202
+
203
+ index += 1
204
+ end
205
+ end
103
206
 
104
- has_layout = node[:width] && node[:height] &&
105
- (node[:width] != 40 || node[:height] != 40)
106
- has_position = node[:x] || node[:y]
107
- has_labels = (node[:labels] || []).any?
108
- has_ports = (node[:ports] || []).any?
109
- has_children = (node[:children] || []).any?
110
- has_edges = (node[:edges] || []).any?
207
+ def emit_shape_reference(section, key, name)
208
+ reference = value(section, key)
209
+ return unless reference
111
210
 
112
- has_layout || has_position || has_labels || has_ports ||
113
- has_children || has_edges
211
+ @output << "#{indentation}#{name}: #{endpoint(reference)}"
114
212
  end
115
213
 
116
- def serialize_node_block(node)
117
- indent = " " * (@indent_level * @indent_size)
214
+ def emit_section_point(section, key, name)
215
+ point = value(section, key)
216
+ return unless point
217
+
218
+ @output << "#{indentation}#{name}: #{formatted_point(point)}"
219
+ end
220
+
221
+ def formatted_point(point)
222
+ "#{format_number(value(point, :x))}, #{format_number(value(point, :y))}"
223
+ end
224
+
225
+ def serialize_label(label)
226
+ id = value(label, :id)
227
+ prefix = id ? "#{mapped_id(id)}: " : ""
228
+ declaration = "label #{prefix}\"#{escape_label(value(label, :text))}\""
229
+
230
+ if label_has_block?(label)
231
+ open_block(declaration) do
232
+ serialize_shape_layout(label)
233
+ serialize_layout_options(value(label, :layoutOptions) || {})
234
+ end
235
+ else
236
+ @output << "#{indentation}#{declaration}"
237
+ end
238
+ end
239
+
240
+ def label_has_block?(label)
241
+ shape_layout_parts(label).any? || hash_present?(label, :layoutOptions)
242
+ end
118
243
 
119
- # Serialize layout attributes
120
- if node[:width] && node[:height]
121
- width = format_number(node[:width])
122
- height = format_number(node[:height])
123
- @output << "#{indent}layout [ size: #{width}, #{height} ]"
244
+ def serialize_shape_layout(shape)
245
+ parts = shape_layout_parts(shape)
246
+ @output << "#{indentation}layout [ #{parts.join(' ')} ]" if parts.any?
247
+ end
248
+
249
+ # Position first, then size: this is the order ELK itself writes.
250
+ def shape_layout_parts(shape)
251
+ parts = []
252
+ x = value(shape, :x)
253
+ y = value(shape, :y)
254
+ width = value(shape, :width)
255
+ height = value(shape, :height)
256
+ parts << "position: #{format_number(x)}, #{format_number(y)}" if x && y
257
+ if width && height
258
+ parts << "size: #{format_number(width)}, #{format_number(height)}"
124
259
  end
260
+ parts
261
+ end
125
262
 
126
- if node[:x] && node[:y]
127
- x = format_number(node[:x])
128
- y = format_number(node[:y])
129
- @output << "#{indent}layout [ position: #{x}, #{y} ]"
263
+ def open_block(declaration)
264
+ @output << "#{indentation}#{declaration} {"
265
+ @indent_level += 1
266
+ yield
267
+ @indent_level -= 1
268
+ @output << "#{indentation}}"
269
+ end
270
+
271
+ def endpoint(id)
272
+ @scope_stack.reverse_each do |scope|
273
+ return scope[id.to_s] if scope.key?(id.to_s)
130
274
  end
275
+ mapped_id(id)
276
+ end
277
+
278
+ def serialized_endpoints(edge, direction)
279
+ port = value(edge, :"#{direction}Port")
280
+ node = Array(value(edge, :"#{direction}s")).first ||
281
+ value(edge, direction)
282
+ return ["#{mapped_id(node)}.#{mapped_id(port)}"] if port
283
+
284
+ Array(value(edge, :"#{direction}s")).map { |id| endpoint(id) }
285
+ end
131
286
 
132
- # Serialize labels
133
- (node[:labels] || []).each do |label|
134
- @output << "#{indent}label \"#{label[:text]}\""
287
+ def prepare_ids(graph)
288
+ ids = []
289
+ graph_id = value(graph, :id)&.to_s
290
+ ids << graph_id if graph_id
291
+ collect_ids(graph, ids)
292
+ collect_root_port_ids(graph, ids)
293
+ @id_map = {}
294
+ used = {}
295
+
296
+ valid, invalid = ids.uniq.partition { |id| valid_identifier?(id) }
297
+ valid.each do |id|
298
+ @id_map[id] = id
299
+ used[id] = true
135
300
  end
136
301
 
137
- # Serialize ports
138
- (node[:ports] || []).each do |port|
139
- serialize_port(port)
302
+ invalid.each do |id|
303
+ base = sanitized_id(id)
304
+ candidate = base
305
+ counter = 2
306
+ while used[candidate]
307
+ candidate = "#{base}_#{counter}"
308
+ counter += 1
309
+ end
310
+ @id_map[id] = candidate
311
+ used[candidate] = true
140
312
  end
313
+ end
141
314
 
142
- # Serialize nested nodes
143
- (node[:children] || []).each do |child|
144
- serialize_node(child)
315
+ def collect_ids(container, ids = [])
316
+ collect_member_ids(value(container, :labels), ids)
317
+ collect_edge_ids(value(container, :edges), ids)
318
+ Array(value(container, :children)).each do |node|
319
+ node_id = value(node, :id)&.to_s
320
+ ids << node_id if node_id
321
+ Array(value(node, :ports)).each do |port|
322
+ port_id = value(port, :id)&.to_s
323
+ next unless port_id
324
+
325
+ ids << port_id
326
+ collect_member_ids(value(port, :labels), ids)
327
+ end
328
+ collect_ids(node, ids)
145
329
  end
330
+ ids
331
+ end
146
332
 
147
- # Serialize nested edges
148
- (node[:edges] || []).each do |edge|
149
- serialize_edge(edge)
333
+ def collect_root_port_ids(graph, ids)
334
+ Array(value(graph, :ports)).each do |port|
335
+ port_id = value(port, :id)&.to_s
336
+ ids << port_id if port_id
337
+ collect_member_ids(value(port, :labels), ids)
150
338
  end
151
339
  end
152
340
 
153
- def serialize_port(port)
154
- indent = " " * (@indent_level * @indent_size)
341
+ def collect_edge_ids(edges, ids)
342
+ Array(edges).each do |edge|
343
+ edge_id = value(edge, :id)&.to_s
344
+ ids << edge_id if edge_id
345
+ collect_member_ids(value(edge, :labels), ids)
346
+ collect_member_ids(value(edge, :sections), ids)
347
+ end
348
+ end
155
349
 
156
- if port_has_block?(port)
157
- @output << "#{indent}port #{port[:id]} {"
158
- @indent_level += 1
350
+ def with_scope(container)
351
+ @scope_stack << endpoint_scope(container)
352
+ yield
353
+ ensure
354
+ @scope_stack.pop
355
+ end
159
356
 
160
- serialize_port_block(port)
357
+ def endpoint_scope(container)
358
+ scope = {}
359
+ children = Array(value(container, :children))
360
+ children.each do |child|
361
+ id = value(child, :id)
362
+ scope[id.to_s] ||= mapped_id(id) if id
363
+ end
364
+ Array(value(container, :ports)).each do |port|
365
+ id = value(port, :id)
366
+ scope[id.to_s] ||= mapped_id(id) if id
367
+ end
368
+ children.each do |child|
369
+ owner = value(child, :id)
370
+ Array(value(child, :ports)).each do |port|
371
+ id = value(port, :id)
372
+ next unless owner && id
373
+
374
+ scope[id.to_s] ||= "#{mapped_id(owner)}.#{mapped_id(id)}"
375
+ end
376
+ end
377
+ scope
378
+ end
161
379
 
162
- @indent_level -= 1
163
- @output << "#{indent}}"
164
- else
165
- @output << "#{indent}port #{port[:id]}"
380
+ def collect_member_ids(members, ids)
381
+ Array(members).each do |member|
382
+ id = value(member, :id)&.to_s
383
+ ids << id if id
166
384
  end
167
385
  end
168
386
 
169
- def port_has_block?(port)
170
- (port[:layoutOptions] || {}).any? ||
171
- (port[:labels] || []).any?
387
+ def mapped_id(id)
388
+ @id_map.fetch(id.to_s, sanitized_id(id.to_s))
172
389
  end
173
390
 
174
- def serialize_port_block(port)
175
- indent = " " * (@indent_level * @indent_size)
391
+ def valid_identifier?(id)
392
+ IDENTIFIER.match?(id) && !KEYWORDS.include?(id)
393
+ end
176
394
 
177
- # Serialize port layout options
178
- (port[:layoutOptions] || {}).each do |key, value|
179
- display_key = key.to_s.start_with?("elk.") ? key.to_s[4..] : key.to_s
180
- @output << "#{indent}#{display_key}: #{format_value(value)}"
395
+ def sanitized_id(id)
396
+ sanitized = id.gsub(/\W/, "_")
397
+ sanitized = "_#{sanitized}" unless sanitized.match?(/\A[A-Za-z_]/)
398
+ sanitized = "_#{sanitized}" if KEYWORDS.include?(sanitized)
399
+ sanitized.empty? ? "_id" : sanitized
400
+ end
401
+
402
+ def property_key(key)
403
+ segments = key.to_s.split(".", -1)
404
+ unless segments.all? { |segment| PROPERTY_SEGMENT.match?(segment) }
405
+ raise Elkrb::ValidationError,
406
+ "ELKT cannot represent option key #{key.inspect}"
181
407
  end
182
408
 
183
- # Serialize port labels
184
- (port[:labels] || []).each do |label|
185
- @output << "#{indent}label \"#{label[:text]}\""
409
+ segments.map do |segment|
410
+ KEYWORDS.include?(segment) ? "^#{segment}" : segment
411
+ end.join(".")
412
+ end
413
+
414
+ def format_value(option_value)
415
+ case option_value
416
+ when Float then format_number(option_value)
417
+ when Integer, TrueClass, FalseClass then option_value.to_s
418
+ when NilClass then "null"
419
+ else format_string_value(option_value.to_s)
186
420
  end
187
421
  end
188
422
 
189
- def serialize_edge(edge)
190
- indent = " " * (@indent_level * @indent_size)
191
-
192
- source = edge[:sources]&.first || edge[:source]
193
- target = edge[:targets]&.first || edge[:target]
194
-
195
- # Add port references if present
196
- source_ref = if edge[:sourcePort]
197
- "#{source}.#{edge[:sourcePort]}"
198
- else
199
- source
200
- end
201
-
202
- target_ref = if edge[:targetPort]
203
- "#{target}.#{edge[:targetPort]}"
204
- else
205
- target
206
- end
207
-
208
- # Include edge ID if it's not auto-generated
209
- @output << if edge[:id] && !edge[:id].to_s.match?(/^e\d+$/)
210
- "#{indent}edge #{edge[:id]}: #{source_ref} -> #{target_ref}"
211
- else
212
- "#{indent}edge #{source_ref} -> #{target_ref}"
213
- end
214
- end
215
-
216
- def format_value(value)
217
- case value
218
- when Float
219
- format_number(value)
220
- when Integer
221
- value
222
- when TrueClass, FalseClass
223
- value
224
- else
225
- value.to_s
423
+ def format_string_value(text)
424
+ return text if valid_value_identifier?(text)
425
+
426
+ "\"#{escape_label(text)}\""
427
+ end
428
+
429
+ def valid_value_identifier?(text)
430
+ text.split(".").all? { |part| valid_identifier?(part) }
431
+ end
432
+
433
+ def escape_label(text)
434
+ text.to_s.gsub(/[\\"\n\r\t\x08\f]/) { |char| LABEL_ESCAPES.fetch(char) }
435
+ end
436
+
437
+ def format_number(number)
438
+ unless number.respond_to?(:finite?) && number.finite?
439
+ raise Elkrb::ValidationError,
440
+ "ELKT numbers must be finite, got #{number.inspect}"
226
441
  end
442
+
443
+ return number.to_i.to_s if number.is_a?(Float) && number == number.to_i
444
+
445
+ number.to_s
446
+ end
447
+
448
+ def indentation
449
+ " " * (@indent_level * @indent_size)
450
+ end
451
+
452
+ def collection?(object, key)
453
+ Array(value(object, key)).any?
227
454
  end
228
455
 
229
- def format_number(num)
230
- # Remove trailing zeros and decimal point if integer
231
- formatted = format("%.2f", num).sub(/\.?0+$/, "")
232
- formatted.empty? ? "0" : formatted
456
+ def hash_present?(object, key)
457
+ (value(object, key) || {}).any?
458
+ end
459
+
460
+ def value(object, key)
461
+ return object[key] if object.key?(key)
462
+
463
+ object[key.to_s]
233
464
  end
234
465
  end
235
466
  end
data/lib/elkrb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elkrb
4
- VERSION = "1.0.2"
4
+ VERSION = "2.0.0"
5
5
  end