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
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
  require "yaml"
5
6
  require "fileutils"
6
7
 
8
+ require_relative "../atomic_destination"
9
+ require_relative "../best_effort_write"
10
+ require_relative "../layout_flags"
11
+
7
12
  module Elkrb
8
13
  module Commands
9
14
  # Command for creating diagrams from ELK graph files
@@ -18,9 +23,10 @@ module Elkrb
18
23
  # Load graph
19
24
  graph = load_graph(@file)
20
25
 
21
- # Apply layout
22
- layout_options = build_layout_options
23
- result = Elkrb::Layout::LayoutEngine.layout(graph, layout_options)
26
+ # Apply layout; explicit flags go onto the root graph, where they
27
+ # outrank the file's own options
28
+ graph = Elkrb::LayoutFlags.apply(graph, @options)
29
+ result = Elkrb::Layout::LayoutEngine.layout(graph, {})
24
30
 
25
31
  # Determine output format
26
32
  output_format = detect_format(@options[:output])
@@ -28,18 +34,31 @@ module Elkrb
28
34
  # Export to format
29
35
  content = export_to_format(result, output_format)
30
36
 
31
- # Write output
32
- write_output(content, @options[:output])
33
-
34
- # Render to image if needed
37
+ # An image format NEVER has DOT written under its own name. The DOT
38
+ # went to `out.svg` first and was read back for rendering, so any
39
+ # failure between those two points left the caller an `out.svg` that
40
+ # began `digraph G{`. Cleanup could not be relied on to undo it
41
+ # either: `FileUtils.rm_f` swallows an unlink failure, so in a
42
+ # non-writable directory the misleading file simply stayed.
43
+ #
44
+ # Staging under a separate name means the image path is only ever
45
+ # written by a render that succeeded.
35
46
  if image_format?(output_format)
36
- render_to_image(@options[:output], output_format)
47
+ render_to_image(content, @options[:output], output_format)
48
+ else
49
+ write_output(content, @options[:output])
37
50
  end
38
51
 
39
52
  # Preview if requested
40
53
  preview(@options[:output]) if @options[:preview]
41
54
 
42
- puts "✓ Diagram created: #{@options[:output]}"
55
+ # Best-effort: the file above is already the result. This
56
+ # confirmation must not decide the exit status -- see
57
+ # Elkrb::BestEffortWrite. This also protects BatchCommand, which
58
+ # calls #run per file inside its own StandardError rescue: an
59
+ # unguarded EPIPE here used to be caught THERE and miscounted a
60
+ # successfully diagrammed file as a processing failure.
61
+ BestEffortWrite.attempt { puts "✓ Diagram created: #{@options[:output]}" }
43
62
  end
44
63
 
45
64
  private
@@ -47,60 +66,8 @@ module Elkrb
47
66
  def load_graph(file)
48
67
  raise ArgumentError, "File not found: #{file}" unless File.exist?(file)
49
68
 
50
- content = File.read(file)
51
- ext = File.extname(file).downcase
52
-
53
- case ext
54
- when ".json"
55
- require_relative "../graph/graph"
56
- Elkrb::Graph::Graph.from_json(content)
57
- when ".yml", ".yaml"
58
- require_relative "../graph/graph"
59
- Elkrb::Graph::Graph.from_yaml(content)
60
- when ".elkt"
61
- require_relative "../parsers/elkt_parser"
62
- Elkrb::Parsers::ElktParser.parse(content)
63
- else
64
- detect_and_parse(content)
65
- end
66
- end
67
-
68
- def detect_and_parse(content)
69
- require_relative "../graph/graph"
70
-
71
- # Try JSON first
72
- begin
73
- return Elkrb::Graph::Graph.from_json(content)
74
- rescue JSON::ParserError
75
- # Not JSON
76
- end
77
-
78
- # Try YAML
79
- begin
80
- return Elkrb::Graph::Graph.from_yaml(content)
81
- rescue Psych::SyntaxError
82
- # Not YAML
83
- end
84
-
85
- # Try ELKT
86
- begin
87
- require_relative "../parsers/elkt_parser"
88
- Elkrb::Parsers::ElktParser.parse(content)
89
- rescue StandardError
90
- raise ArgumentError,
91
- "Unable to parse input file. Supported formats: JSON, YAML, ELKT"
92
- end
93
- end
94
-
95
- def build_layout_options
96
- opts = {}
97
-
98
- opts[:algorithm] = @options[:algorithm] if @options[:algorithm]
99
- opts[:direction] = @options[:direction] if @options[:direction]
100
- opts[:spacing_node_node] = @options[:spacing] if @options[:spacing]
101
- opts[:edge_routing] = @options[:edge_routing] if @options[:edge_routing]
102
-
103
- opts
69
+ require_relative "../format_sniffer"
70
+ Elkrb::FormatSniffer.read(File.read(file), File.extname(file).downcase)
104
71
  end
105
72
 
106
73
  def detect_format(filename)
@@ -146,63 +113,228 @@ module Elkrb
146
113
  end
147
114
 
148
115
  def write_output(content, filename)
149
- dir = File.dirname(filename)
150
- FileUtils.mkdir_p(dir)
151
-
152
- File.write(filename, content)
116
+ AtomicDestination.write(filename, content)
153
117
  end
154
118
 
119
+ IMAGE_FORMATS = %i[png svg pdf ps eps].freeze
120
+ private_constant :IMAGE_FORMATS
121
+
155
122
  def image_format?(format)
156
- %i[png svg pdf ps eps].include?(format)
123
+ IMAGE_FORMATS.include?(format)
157
124
  end
158
125
 
159
- def render_to_image(output_file, format)
160
- # For image formats, we need to render DOT -> image
161
- # First write DOT to temp file
162
- dot_file = "#{output_file}.tmp.dot"
126
+ # Renders to a SCRATCH path and moves it into place only once the
127
+ # render has succeeded. Nothing else makes the guarantee hold.
128
+ #
129
+ # Rendering straight to `output_file` and cleaning up afterwards does
130
+ # not work, and both attempts at it failed differently. Deleting
131
+ # unconditionally destroyed a file the caller already had there.
132
+ # Deleting only when we had created it left a TRUNCATED image behind --
133
+ # measured, a 1024-byte file beginning `<?xml` from a render that died
134
+ # partway, sitting under the caller's own filename. `File.rename` is
135
+ # atomic within a filesystem, so the image path either holds the
136
+ # previous content or a complete render, never a fragment.
137
+
138
+ def render_to_image(dot_content, output_file, format)
139
+ # A scratch DIRECTORY, created exclusively (`Dir.mkdir` refuses an
140
+ # existing path), holding both scratch files under plain, unguessable
141
+ # names -- a fixed name like `out.svg.tmp.svg` lets a symlink there
142
+ # redirect a failed render's partial output onto the caller's file.
143
+ # It lives in the DESTINATION directory, which keeps `File.rename`
144
+ # on one filesystem and therefore atomic.
145
+ FileUtils.mkdir_p(File.dirname(output_file))
146
+ scratch, identity, token = scratch_dir(output_file)
147
+ dot_file = File.join(scratch, "graph-#{token}.dot")
148
+ image_file = File.join(scratch, "image-#{token}.#{format}")
149
+
150
+ # Each scratch file is recorded with its identity the moment it comes
151
+ # into existence, and cleanup unlinks a path only while it still
152
+ # names that same inode. The token in the name is defence in depth,
153
+ # not the guarantee: the path is an argument to `dot`, so it shows up
154
+ # in `ps auxww` to anybody on the machine -- measured. Identity does
155
+ # not rely on the name staying secret.
156
+ owned = []
163
157
 
164
- # Re-read the content we just wrote (which is DOT)
165
- dot_content = File.read(output_file)
166
-
167
- # If output is not already DOT, we need to export it
168
- if File.extname(output_file).downcase == ".dot"
169
- dot_file = output_file
170
- else
171
- File.write(dot_file, dot_content)
172
- end
173
-
174
- # Render using Graphviz
175
158
  begin
159
+ # Both records are taken in an `ensure`. A step that raises PARTWAY
160
+ # has still created the file, and a file whose identity was never
161
+ # recorded is never unlinked -- which would leave the directory
162
+ # non-empty and leak it.
163
+ begin
164
+ write_output(dot_content, dot_file)
165
+ ensure
166
+ record_owned(owned, dot_file)
167
+ end
168
+
176
169
  require_relative "../graphviz_wrapper"
177
170
  graphviz = Elkrb::GraphvizWrapper.new
178
171
 
179
- unless graphviz.available?
180
- warn "⚠ Graphviz not found. Cannot render to #{format}."
181
- warn " Install Graphviz or export to DOT format instead."
182
- return
172
+ begin
173
+ graphviz.render(dot_file, image_file, format, engine: "dot",
174
+ dpi: 96)
175
+ ensure
176
+ record_owned(owned, image_file)
177
+ end
178
+
179
+ # A renderer can exit 0 and write nothing. Renaming that over the
180
+ # requested name would destroy the caller's file and leave an
181
+ # invalid image wearing its name -- measured with a stub renderer
182
+ # that simply succeeds.
183
+ unless File.file?(image_file) && File.size(image_file).positive?
184
+ raise Elkrb::Error,
185
+ "#{graphviz_name} reported success but produced no image"
183
186
  end
184
187
 
185
- graphviz.render(dot_file, output_file, format, engine: "dot", dpi: 96)
188
+ File.rename(image_file, output_file)
189
+ ensure
190
+ remove_scratch(scratch, identity, owned)
191
+ end
192
+ end
193
+
194
+ def graphviz_name
195
+ "graphviz"
196
+ end
197
+
198
+ # A directory in the destination that did not exist a moment ago and
199
+ # that this process created. `Dir.mkdir` raises EEXIST rather than
200
+ # opening what is already there, which is what makes that true.
201
+ #
202
+ # Returns the path AND the directory's identity -- device and inode --
203
+ # because a path is not a durable handle. Cleanup compares that identity
204
+ # before removing anything, so if the entry is swapped for a different
205
+ # real directory in between, that directory is left alone.
206
+ def scratch_dir(output_file)
207
+ dir = File.dirname(output_file)
208
+
209
+ loop do
210
+ # A second token, for the names of the files INSIDE the directory.
211
+ # This is defence in depth and nothing rests on it: the dot file
212
+ # path is an argument to `dot`, so `ps auxww` shows it to anybody
213
+ # on the machine -- measured. What actually decides a delete is the
214
+ # recorded inode, not the name.
215
+ token = SecureRandom.hex(6)
216
+ candidate = File.join(dir, ".elkrb-#{SecureRandom.hex(6)}")
217
+ begin
218
+ Dir.mkdir(candidate)
219
+ rescue Errno::EEXIST
220
+ next
221
+ end
186
222
 
187
- # Clean up temp DOT file if we created one
188
- File.delete(dot_file) if dot_file != output_file && File.exist?(dot_file)
189
- rescue Elkrb::GraphvizWrapper::GraphvizNotFoundError => e
190
- warn "⚠ #{e.message}"
223
+ return claim_scratch(candidate, token)
191
224
  end
192
225
  end
193
226
 
194
- def preview(file)
195
- case RbConfig::CONFIG["host_os"]
196
- when /darwin/
197
- system("open", file)
198
- when /linux/
199
- system("xdg-open", file)
200
- when /mswin|mingw|cygwin/
201
- system("start", file)
202
- else
203
- warn "Preview not supported on this platform"
227
+ # Everything from here has already created the directory, and the
228
+ # caller's `ensure` has not begun yet -- so this is the only place that
229
+ # can clean it up. A chmod that raised used to leak it.
230
+ def claim_scratch(candidate, token)
231
+ identity = directory_identity(candidate)
232
+ # `Dir.mkdir`'s mode is masked by the umask, so under `umask 0222`
233
+ # the directory arrives read-only and nothing can be written inside
234
+ # it. Set the mode after creating, not as an argument.
235
+ FileUtils.chmod(0o700, candidate)
236
+ [candidate, identity, token]
237
+ rescue StandardError
238
+ discard_claim(candidate, identity)
239
+ raise
240
+ end
241
+
242
+ # Cleanup for a directory created moments ago, where the failure may be
243
+ # the identity capture ITSELF -- so `identity` can be nil here.
244
+ #
245
+ # With an identity, compare it. Without one, remove NON-recursively: the
246
+ # directory we made is still empty, and `Dir.rmdir` refuses one that is
247
+ # not, so anything a replacement contains survives. A recursive delete
248
+ # by path here would take that replacement and everything in it --
249
+ # measured, it did.
250
+ def discard_claim(candidate, identity)
251
+ return remove_scratch(candidate, identity, []) if identity
252
+
253
+ begin
254
+ Dir.rmdir(candidate)
255
+ rescue SystemCallError
256
+ nil
204
257
  end
205
258
  end
259
+
260
+ # Mirrors file_identity's own rescue: a TOCTOU gap between a caller's
261
+ # existence check and this lstat -- the directory removed or replaced
262
+ # in between -- must not raise out of remove_scratch's ensure and mask
263
+ # whatever real failure it was cleaning up after.
264
+ def directory_identity(path)
265
+ stat = File.lstat(path)
266
+ [stat.dev, stat.ino]
267
+ rescue SystemCallError
268
+ nil
269
+ end
270
+
271
+ # Removes the directory ONLY if the path still names the one whose
272
+ # identity was captured, and removes no FILE it did not create --
273
+ # `entries` are `[path, identity]` pairs for files this render
274
+ # actually made. No recursive delete here on purpose: comparing the
275
+ # identity and then calling `FileUtils.remove_entry` were two separate
276
+ # operations, and a directory substituted between them was removed
277
+ # with everything in it.
278
+ def remove_scratch(path, identity, entries = [])
279
+ return if identity.nil?
280
+ return unless File.directory?(path)
281
+ return unless directory_identity(path) == identity
282
+
283
+ entries.each { |entry, entry_identity| unlink_owned(entry, entry_identity) }
284
+ remove_empty_directory(path, identity)
285
+ end
286
+
287
+ # Identity, not name. A file that arrived at this path from anywhere
288
+ # else has a different inode and is left where it is.
289
+ def unlink_owned(path, identity)
290
+ return if identity.nil?
291
+ return unless file_identity(path) == identity
292
+
293
+ File.unlink(path)
294
+ rescue SystemCallError
295
+ nil
296
+ end
297
+
298
+ def record_owned(owned, path)
299
+ identity = file_identity(path)
300
+ owned << [path, identity] if identity
301
+ end
302
+
303
+ def file_identity(path)
304
+ stat = File.lstat(path)
305
+ [stat.dev, stat.ino]
306
+ rescue SystemCallError
307
+ nil
308
+ end
309
+
310
+ # Scoped so a missing DESCENDANT cannot be mistaken for a missing root:
311
+ # a method-wide ENOENT rescue would swallow that and leave the
312
+ # directory. The identity is compared AGAIN here, after the unlinks,
313
+ # right before the call that removes the directory -- this narrows the
314
+ # race window but cannot close it; the bound is that `Dir.rmdir`
315
+ # refuses a directory that is not empty, so what can be lost here is
316
+ # an EMPTY directory and never a file.
317
+ def remove_empty_directory(path, identity)
318
+ return unless directory_identity(path) == identity
319
+
320
+ Dir.rmdir(path)
321
+ rescue SystemCallError
322
+ nil
323
+ end
324
+
325
+ def preview(file)
326
+ launcher = case RbConfig::CONFIG["host_os"]
327
+ when /darwin/ then ["open", file]
328
+ when /linux/ then ["xdg-open", file]
329
+ when /mswin|mingw|cygwin/
330
+ ["cmd", "/c", "start", "", file]
331
+ end
332
+
333
+ raise Error, "Preview is not supported on this platform" unless launcher
334
+ return if system(*launcher)
335
+
336
+ raise Error, "Unable to open preview: #{file}"
337
+ end
206
338
  end
207
339
  end
208
340
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../errors"
6
+ require_relative "../options/registry"
7
+ require_relative "../layout/algorithm_registry"
8
+
9
+ module Elkrb
10
+ module Commands
11
+ # Prints the layout option registry: every option elkrb knows, its type,
12
+ # default and truthfulness status, optionally narrowed to one algorithm.
13
+ class OptionsCommand
14
+ COLUMNS = %w[id type default status aliases].freeze
15
+ private_constant :COLUMNS
16
+
17
+ def initialize(algorithm, options)
18
+ @algorithm = algorithm
19
+ @options = options
20
+ end
21
+
22
+ def run
23
+ puts @options[:json] ? JSON.generate("options" => rows) : table(rows)
24
+ end
25
+
26
+ private
27
+
28
+ def rows
29
+ owners = owners_by_option
30
+ ids.map { |id| row(id, owners.fetch(id, [])) }
31
+ end
32
+
33
+ def row(id, algorithms)
34
+ entry = Options::Registry.all.fetch(id)
35
+ {
36
+ "id" => id,
37
+ "type" => entry[:type].to_s,
38
+ "default" => entry[:default],
39
+ "status" => entry[:status].to_s,
40
+ "aliases" => Array(entry[:aliases]),
41
+ "algorithms" => algorithms,
42
+ }
43
+ end
44
+
45
+ def ids
46
+ return Options::Registry.all.keys unless @algorithm
47
+
48
+ info = Layout::AlgorithmRegistry.algorithm_info(@algorithm)
49
+ raise AlgorithmNotFoundError, @algorithm unless info
50
+
51
+ info[:supported_options]
52
+ end
53
+
54
+ # option id => ids of the registered algorithms that support it
55
+ def owners_by_option
56
+ Layout::AlgorithmRegistry.all_algorithm_info
57
+ .each_with_object(Hash.new { |h, k| h[k] = [] }) do |info, owners|
58
+ info[:supported_options].each { |id| owners[id] << info[:id] }
59
+ end
60
+ end
61
+
62
+ def table(rows)
63
+ lines = [COLUMNS, *rows.map { |row| cells(row) }]
64
+ widths = column_widths(lines)
65
+ lines.map { |line| pad(line, widths) }
66
+ end
67
+
68
+ def column_widths(lines)
69
+ lines.transpose.map { |column| column.map(&:length).max }
70
+ end
71
+
72
+ def pad(line, widths)
73
+ line.zip(widths).map { |text, width| text.ljust(width) }
74
+ .join(" ").rstrip
75
+ end
76
+
77
+ def cells(row)
78
+ [row["id"], row["type"], display(row["default"]), row["status"],
79
+ row["aliases"].join(",")]
80
+ end
81
+
82
+ def display(value)
83
+ value.nil? ? "-" : value.to_s
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../best_effort_write"
4
+
3
5
  module Elkrb
4
6
  module Commands
5
7
  # Command for rendering DOT files to images using Graphviz
@@ -28,7 +30,10 @@ module Elkrb
28
30
  graphviz.render(@dot_file, @options[:output], format, engine: engine,
29
31
  dpi: dpi)
30
32
 
31
- puts "✓ Rendered #{@dot_file} → #{@options[:output]}"
33
+ # Best-effort: the image above is already the result. This
34
+ # confirmation must not decide the exit status -- see
35
+ # Elkrb::BestEffortWrite.
36
+ BestEffortWrite.attempt { puts "✓ Rendered #{@dot_file} → #{@options[:output]}" }
32
37
  end
33
38
 
34
39
  private