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.
- checksums.yaml +4 -4
- data/CHANGELOG.adoc +364 -0
- data/LICENSE +25 -0
- data/README.adoc +695 -557
- data/exe/elkrb +36 -2
- data/lib/elkrb/atomic_destination.rb +53 -0
- data/lib/elkrb/best_effort_write.rb +49 -0
- data/lib/elkrb/cli/layout_flag_options.rb +54 -0
- data/lib/elkrb/cli.rb +155 -82
- data/lib/elkrb/command_resolver.rb +186 -0
- data/lib/elkrb/commands/batch_command.rb +28 -5
- data/lib/elkrb/commands/convert_command.rb +22 -52
- data/lib/elkrb/commands/diagram_command.rb +234 -102
- data/lib/elkrb/commands/options_command.rb +87 -0
- data/lib/elkrb/commands/render_command.rb +6 -1
- data/lib/elkrb/commands/validate_command.rb +146 -91
- data/lib/elkrb/errors.rb +20 -1
- data/lib/elkrb/format_sniffer.rb +362 -0
- data/lib/elkrb/geometry/bezier.rb +14 -4
- data/lib/elkrb/geometry/point.rb +5 -6
- data/lib/elkrb/geometry/rectangle.rb +2 -2
- data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
- data/lib/elkrb/graph/edge.rb +89 -3
- data/lib/elkrb/graph/graph.rb +43 -15
- data/lib/elkrb/graph/label.rb +16 -2
- data/lib/elkrb/graph/node.rb +13 -2
- data/lib/elkrb/graph/node_constraints.rb +139 -8
- data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
- data/lib/elkrb/graph/port.rb +29 -21
- data/lib/elkrb/graph/read_only_mapping.rb +21 -0
- data/lib/elkrb/graphviz_wrapper.rb +152 -44
- data/lib/elkrb/layout/algorithm_registry.rb +65 -8
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
- data/lib/elkrb/layout/algorithms/box.rb +47 -20
- data/lib/elkrb/layout/algorithms/disco.rb +150 -61
- data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
- data/lib/elkrb/layout/algorithms/force.rb +145 -86
- data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
- data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
- data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
- data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
- data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
- data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
- data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
- data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
- data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
- data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
- data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
- data/lib/elkrb/layout/algorithms/layered.rb +548 -13
- data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
- data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
- data/lib/elkrb/layout/algorithms/radial.rb +188 -29
- data/lib/elkrb/layout/algorithms/random.rb +131 -17
- data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
- data/lib/elkrb/layout/algorithms/stress.rb +391 -84
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
- data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
- data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
- data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
- data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
- data/lib/elkrb/layout/edge_router.rb +533 -337
- data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
- data/lib/elkrb/layout/java_random.rb +59 -0
- data/lib/elkrb/layout/label_placer.rb +165 -157
- data/lib/elkrb/layout/layout_engine.rb +46 -23
- data/lib/elkrb/layout/node_index.rb +149 -0
- data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
- data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
- data/lib/elkrb/layout/polyomino/grid.rb +106 -0
- data/lib/elkrb/layout/polyomino/packer.rb +95 -0
- data/lib/elkrb/layout/polyomino/piece.rb +52 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
- data/lib/elkrb/layout/spore/body.rb +65 -0
- data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
- data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
- data/lib/elkrb/layout/spore/gap.rb +76 -0
- data/lib/elkrb/layout/spore/geometry.rb +104 -0
- data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
- data/lib/elkrb/layout/spore/placement.rb +57 -0
- data/lib/elkrb/layout/spore/rays.rb +119 -0
- data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
- data/lib/elkrb/layout/spore/triangulation.rb +159 -0
- data/lib/elkrb/layout_flags.rb +69 -0
- data/lib/elkrb/options/decimal.rb +50 -0
- data/lib/elkrb/options/element_options.rb +43 -0
- data/lib/elkrb/options/elk_padding.rb +20 -14
- data/lib/elkrb/options/k_vector.rb +6 -7
- data/lib/elkrb/options/k_vector_chain.rb +12 -20
- data/lib/elkrb/options/option_map.rb +85 -0
- data/lib/elkrb/options/registry.rb +335 -0
- data/lib/elkrb/options/resolver.rb +151 -0
- data/lib/elkrb/options/spellings.rb +62 -0
- data/lib/elkrb/options/unhonoured_report.rb +194 -0
- data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
- data/lib/elkrb/parsers/elkt/parser.rb +424 -0
- data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
- data/lib/elkrb/parsers/elkt/token.rb +40 -0
- data/lib/elkrb/parsers/elkt_parser.rb +32 -230
- data/lib/elkrb/serializers/dot_serializer.rb +342 -54
- data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
- data/lib/elkrb/version.rb +1 -1
- data/lib/elkrb.rb +60 -61
- metadata +71 -49
- data/.rspec +0 -3
- data/.rubocop.yml +0 -11
- data/Gemfile +0 -13
- data/Rakefile +0 -64
- data/benchmarks/README.md +0 -172
- data/benchmarks/elkjs_benchmark.js +0 -140
- data/benchmarks/elkrb_benchmark.rb +0 -145
- data/benchmarks/fixtures/graphs.json +0 -10777
- data/benchmarks/generate_report.rb +0 -241
- data/benchmarks/generate_test_graphs.rb +0 -154
- data/benchmarks/results/elkrb_results.json +0 -280
- data/benchmarks/results/elkrb_summary.json +0 -285
- data/elkrb.gemspec +0 -39
- data/examples/dot_export_demo.rb +0 -133
- data/examples/hierarchical_graph.rb +0 -19
- data/examples/layout_constraints_demo.rb +0 -272
- data/examples/port_constraints_demo.rb +0 -291
- data/examples/self_loop_demo.rb +0 -391
- data/examples/simple_graph.rb +0 -50
- data/examples/spline_routing_demo.rb +0 -235
- data/lib/elkrb/graph/layout_options.rb +0 -247
- data/sig/elkrb/constraints.rbs +0 -114
- data/sig/elkrb/geometry.rbs +0 -61
- data/sig/elkrb/graph.rbs +0 -112
- data/sig/elkrb/layout.rbs +0 -107
- data/sig/elkrb/options.rbs +0 -81
- 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
|
-
|
|
23
|
-
|
|
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
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
IMAGE_FORMATS.include?(format)
|
|
157
124
|
end
|
|
158
125
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|