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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
# Resolves a command name to the file the OS would attempt to execute for
|
|
5
|
+
# it, without running it. That is strictly weaker than "it will run": a
|
|
6
|
+
# script whose interpreter is missing is a regular file, is executable, and
|
|
7
|
+
# still fails to exec. What this establishes is WHICH file the name reaches.
|
|
8
|
+
# Ruby execs a metacharacter-free command string itself and searches PATH
|
|
9
|
+
# for it, so a name with a separator is used as written and a bare name is
|
|
10
|
+
# looked up along PATH. Asking that question is a job of its own, separate
|
|
11
|
+
# from running the command, and it carries no instance state -- the answer
|
|
12
|
+
# depends on PATH, the filesystem and the working directory, none of which
|
|
13
|
+
# this unit owns.
|
|
14
|
+
module CommandResolver
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# The first candidate that resolves, or nil.
|
|
18
|
+
def resolve(candidates)
|
|
19
|
+
candidates.find { |path| runnable?(path) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The absolute file a bare name resolves to via PATH, or the matched
|
|
23
|
+
# candidate for a name that already carries a separator -- nil when
|
|
24
|
+
# `runnable?` is false.
|
|
25
|
+
#
|
|
26
|
+
# Return the candidate that MATCHED, never `path` or
|
|
27
|
+
# `File.join(dir, path)`. On Windows `executable_candidates` fans an
|
|
28
|
+
# extensionless name out to its PATHEXT forms ("dot" -> "dot.exe"), so
|
|
29
|
+
# `path` is the form guaranteed NOT to exist and `File.realpath` would
|
|
30
|
+
# resolve it against the working directory instead of the PATH entry
|
|
31
|
+
# that matched.
|
|
32
|
+
def resolved_path(path)
|
|
33
|
+
return matched_candidate(path) if separator_form?(path)
|
|
34
|
+
|
|
35
|
+
dir = path_directories.find { |each| resolves_in_directory?(each, path) }
|
|
36
|
+
dir && matched_candidate(path, dir)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Re-finds the candidate `runnable?` already confirmed, because neither
|
|
40
|
+
# it nor `resolves_in_directory?` keeps a reference to it.
|
|
41
|
+
def matched_candidate(path, dir = nil)
|
|
42
|
+
found = executable_candidates(path).find do |each|
|
|
43
|
+
executable_file?(dir ? File.join(dir, each) : each)
|
|
44
|
+
end
|
|
45
|
+
found && (dir ? File.join(dir, found) : found)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `File.executable?` on a bare name answers about the WORKING DIRECTORY
|
|
49
|
+
# rather than about PATH, which is why it is not asked here.
|
|
50
|
+
#
|
|
51
|
+
# On Windows, CreateProcess (what `system`/backtick ultimately calls) does
|
|
52
|
+
# not require an exact filename match: given "dot" it also tries "dot"
|
|
53
|
+
# suffixed with each extension in PATHEXT, in order, both for a bare name
|
|
54
|
+
# walked along PATH and for a name given with a separator. POSIX exec has
|
|
55
|
+
# no such behaviour, so `executable_candidates` is a no-op there.
|
|
56
|
+
def runnable?(path)
|
|
57
|
+
if separator_form?(path)
|
|
58
|
+
executable_candidates(path).any? do |candidate|
|
|
59
|
+
executable_file?(candidate)
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
path_directories.any? { |dir| resolves_in_directory?(dir, path) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def separator_form?(path)
|
|
67
|
+
return true if path.include?(File::SEPARATOR)
|
|
68
|
+
|
|
69
|
+
windows? && File::ALT_SEPARATOR && path.include?(File::ALT_SEPARATOR)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolves_in_directory?(dir, path)
|
|
73
|
+
executable_candidates(path).any? do |candidate|
|
|
74
|
+
executable_file?(File.join(dir, candidate))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# `File.executable?` alone is true for a DIRECTORY, because directories
|
|
79
|
+
# are searchable, and exec is not. `File.file?` alone is true for a data
|
|
80
|
+
# file. Both conjuncts are load-bearing.
|
|
81
|
+
def executable_file?(path)
|
|
82
|
+
File.file?(path) && File.executable?(path)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A path that already carries a PATHEXT-listed extension (e.g. "dot.exe")
|
|
86
|
+
# is used as written -- only an EXTENSION-LESS name gets the PATHEXT
|
|
87
|
+
# fan-out, matching what CreateProcess actually does.
|
|
88
|
+
def executable_candidates(path)
|
|
89
|
+
return [path] unless windows?
|
|
90
|
+
return [path] if pathext.include?(File.extname(path).upcase)
|
|
91
|
+
|
|
92
|
+
pathext_candidates(path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The Windows filesystem is case-insensitive, so CreateProcess matches
|
|
96
|
+
# "dot.exe" whether PATHEXT names it ".EXE" or ".exe". Trying both cases
|
|
97
|
+
# here matches that on a case-SENSITIVE filesystem too (Linux/macOS CI
|
|
98
|
+
# running this logic under a Windows stub), rather than depending on the
|
|
99
|
+
# host filesystem's case sensitivity to agree.
|
|
100
|
+
def pathext_candidates(path)
|
|
101
|
+
pathext.flat_map do |extension|
|
|
102
|
+
["#{path}#{extension}", "#{path}#{extension.downcase}"]
|
|
103
|
+
end.uniq
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# PATHEXT is always ";"-delimited on Windows, independent of
|
|
107
|
+
# `File::PATH_SEPARATOR` -- which on a non-Windows host running under a
|
|
108
|
+
# stubbed `windows?` (as the specs do, to test this logic on any CI
|
|
109
|
+
# platform) is ":" and would silently produce one useless candidate.
|
|
110
|
+
# An entry missing its leading dot (a malformed but observed PATHEXT,
|
|
111
|
+
# e.g. "EXE" instead of ".EXE") is normalised here rather than left to
|
|
112
|
+
# silently never match `File.extname`, which always includes the dot.
|
|
113
|
+
def pathext
|
|
114
|
+
raw = ENV.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
|
|
115
|
+
raw.map(&:strip).reject(&:empty?)
|
|
116
|
+
.map { |ext| ext.start_with?(".") ? ext : ".#{ext}" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def windows?
|
|
120
|
+
Gem.win_platform?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Keep the `.b` and `split(..., -1)`: a raw (non-UTF-8) PATH byte must not
|
|
124
|
+
# raise, and a trailing/empty field must not be dropped -- both silently
|
|
125
|
+
# break resolution for a later, valid PATH entry otherwise. This is a
|
|
126
|
+
# documented CONTAINMENT, not full parity with exec: an unset PATH here
|
|
127
|
+
# is treated as empty, while exec also searches libruby's compiled-in
|
|
128
|
+
# default list (see gate record for the measured value) -- so this can
|
|
129
|
+
# under-report but never over-report a directory exec would search.
|
|
130
|
+
def path_directories
|
|
131
|
+
fields = ENV.fetch("PATH", "").b.split(File::PATH_SEPARATOR, -1)
|
|
132
|
+
fields = [""] if fields.empty?
|
|
133
|
+
fields.map { |field| field.empty? ? "." : expand_tilde(field) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Expand only a bare `~` or `~/...` PATH entry against `$HOME`, matching
|
|
137
|
+
# what the shell does before `render`'s `system(cmd)` ever sees PATH.
|
|
138
|
+
# Leave `~user` (a third party's home directory) un-expanded rather than
|
|
139
|
+
# querying the OS user database -- it still cannot false-positive-match
|
|
140
|
+
# via `executable_file?`, only under-report, same as `path_directories`.
|
|
141
|
+
def expand_tilde(field)
|
|
142
|
+
return field unless field.start_with?("~")
|
|
143
|
+
|
|
144
|
+
tilde_expansion(field) || field
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Split from `expand_tilde` so each holds one job: this one turns a
|
|
148
|
+
# confirmed `~` PATH entry into its expansion or nothing, and never
|
|
149
|
+
# touches a field that is not one.
|
|
150
|
+
def tilde_expansion(field)
|
|
151
|
+
rest = tilde_rest(field)
|
|
152
|
+
return unless rest
|
|
153
|
+
|
|
154
|
+
home = home_directory
|
|
155
|
+
return if home.to_s.empty?
|
|
156
|
+
|
|
157
|
+
"#{home.b}#{rest}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# `Dir.home` (not `ENV["HOME"]` directly) so this agrees with what the
|
|
161
|
+
# shell itself consults for `~` expansion; it raises when no home
|
|
162
|
+
# directory can be determined at all, rather than returning nil, so
|
|
163
|
+
# that is the one case caught here. The rescue body is empty on
|
|
164
|
+
# purpose -- it already evaluates to `nil`, so an explicit `nil` line
|
|
165
|
+
# would be a no-op.
|
|
166
|
+
def home_directory
|
|
167
|
+
Dir.home
|
|
168
|
+
rescue ArgumentError
|
|
169
|
+
# empty on purpose -- already evaluates to nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# `nil` means "not a `~` or `~/...` PATH entry" -- distinct from `""`,
|
|
173
|
+
# which is the valid rest of a bare `~`. `field[1..]` is never nil here:
|
|
174
|
+
# `field` always has at least one byte (the leading `~`), so index 1 is
|
|
175
|
+
# at worst equal to its length, which slices to `""` rather than failing.
|
|
176
|
+
def tilde_rest(field)
|
|
177
|
+
rest = field[1..]
|
|
178
|
+
rest if rest.empty? || rest.start_with?(File::SEPARATOR)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Internal. Kept off the public surface deliberately; `private_constant` sits
|
|
184
|
+
# out here because inside the namespace it makes `Elkrb` a non-namespacing
|
|
185
|
+
# module, which reek then requires a descriptive comment for.
|
|
186
|
+
Elkrb.private_constant :CommandResolver
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
|
+
require_relative "../best_effort_write"
|
|
6
|
+
|
|
5
7
|
module Elkrb
|
|
6
8
|
module Commands
|
|
7
9
|
# Command for processing multiple graph files in batch
|
|
@@ -23,7 +25,13 @@ module Elkrb
|
|
|
23
25
|
files = Dir.glob(pattern, File::FNM_EXTGLOB)
|
|
24
26
|
|
|
25
27
|
if files.empty?
|
|
26
|
-
|
|
28
|
+
# Best-effort: this notice carries no result -- there is nothing
|
|
29
|
+
# left to do either way, so a dead stdout must not decide the
|
|
30
|
+
# exit status here any more than it does after real work below.
|
|
31
|
+
# See Elkrb::BestEffortWrite.
|
|
32
|
+
BestEffortWrite.attempt do
|
|
33
|
+
puts "No graph files found in #{@directory}"
|
|
34
|
+
end
|
|
27
35
|
return
|
|
28
36
|
end
|
|
29
37
|
|
|
@@ -42,14 +50,29 @@ module Elkrb
|
|
|
42
50
|
warn "⚠ Error processing #{file}: #{e.message}"
|
|
43
51
|
end
|
|
44
52
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
# Best-effort: every file above is already written to output_dir.
|
|
54
|
+
# This summary must not decide the exit status -- see
|
|
55
|
+
# Elkrb::BestEffortWrite. fail_on_errors decides it instead.
|
|
56
|
+
BestEffortWrite.attempt do
|
|
57
|
+
puts ""
|
|
58
|
+
puts "✓ Processed #{success_count} file(s) → #{@options[:output_dir]}"
|
|
59
|
+
puts "⚠ #{error_count} error(s)" if error_count.positive?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
fail_on_errors(error_count, files.size)
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
private
|
|
52
66
|
|
|
67
|
+
# The summary alone would let a batch that failed on every file still
|
|
68
|
+
# exit 0, so a non-empty error count has to reach the caller.
|
|
69
|
+
def fail_on_errors(error_count, total)
|
|
70
|
+
return unless error_count.positive?
|
|
71
|
+
|
|
72
|
+
require_relative "../errors"
|
|
73
|
+
raise Elkrb::Error, "#{error_count} of #{total} file(s) failed"
|
|
74
|
+
end
|
|
75
|
+
|
|
53
76
|
def process_file(file)
|
|
54
77
|
basename = File.basename(file, File.extname(file))
|
|
55
78
|
output_file = File.join(@options[:output_dir],
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
require_relative "../atomic_destination"
|
|
7
|
+
require_relative "../best_effort_write"
|
|
6
8
|
|
|
7
9
|
module Elkrb
|
|
8
10
|
module Commands
|
|
@@ -17,6 +19,7 @@ module Elkrb
|
|
|
17
19
|
def run
|
|
18
20
|
# Load source file
|
|
19
21
|
graph = load_any_format(@file)
|
|
22
|
+
graph = graph_from_hash(graph) if graph.is_a?(Hash)
|
|
20
23
|
|
|
21
24
|
# Detect target format
|
|
22
25
|
target_format = detect_format(@options[:output])
|
|
@@ -24,63 +27,28 @@ module Elkrb
|
|
|
24
27
|
# Convert
|
|
25
28
|
content = export_to_format(graph, target_format)
|
|
26
29
|
|
|
27
|
-
# Write output
|
|
30
|
+
# Write output -- this is the result. Everything after it is best-effort.
|
|
28
31
|
write_output(content, @options[:output])
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
report_success(target_format)
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
private
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
case ext
|
|
42
|
-
when ".json"
|
|
43
|
-
require_relative "../graph/graph"
|
|
44
|
-
Elkrb::Graph::Graph.from_json(content)
|
|
45
|
-
when ".yml", ".yaml"
|
|
46
|
-
require_relative "../graph/graph"
|
|
47
|
-
Elkrb::Graph::Graph.from_yaml(content)
|
|
48
|
-
when ".elkt"
|
|
49
|
-
require_relative "../parsers/elkt_parser"
|
|
50
|
-
Elkrb::Parsers::ElktParser.parse(content)
|
|
51
|
-
when ".dot", ".gv"
|
|
52
|
-
raise ArgumentError,
|
|
53
|
-
"DOT format input not yet supported. Use JSON, YAML, or ELKT."
|
|
54
|
-
else
|
|
55
|
-
detect_and_parse(content)
|
|
38
|
+
# Best-effort: the file written above is already the result. This
|
|
39
|
+
# confirmation must not decide the exit status -- see
|
|
40
|
+
# Elkrb::BestEffortWrite.
|
|
41
|
+
def report_success(target_format)
|
|
42
|
+
BestEffortWrite.attempt do
|
|
43
|
+
puts "✓ Converted #{@file} → #{@options[:output]} (#{target_format})"
|
|
56
44
|
end
|
|
57
45
|
end
|
|
58
46
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# Try JSON first
|
|
63
|
-
begin
|
|
64
|
-
return Elkrb::Graph::Graph.from_json(content)
|
|
65
|
-
rescue JSON::ParserError
|
|
66
|
-
# Not JSON
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Try YAML
|
|
70
|
-
begin
|
|
71
|
-
return Elkrb::Graph::Graph.from_yaml(content)
|
|
72
|
-
rescue Psych::SyntaxError
|
|
73
|
-
# Not YAML
|
|
74
|
-
end
|
|
47
|
+
def load_any_format(file)
|
|
48
|
+
raise ArgumentError, "File not found: #{file}" unless File.exist?(file)
|
|
75
49
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
require_relative "../parsers/elkt_parser"
|
|
79
|
-
Elkrb::Parsers::ElktParser.parse(content)
|
|
80
|
-
rescue StandardError
|
|
81
|
-
raise ArgumentError,
|
|
82
|
-
"Unable to parse input file. Supported formats: JSON, YAML, ELKT"
|
|
83
|
-
end
|
|
50
|
+
require_relative "../format_sniffer"
|
|
51
|
+
Elkrb::FormatSniffer.read(File.read(file), File.extname(file).downcase)
|
|
84
52
|
end
|
|
85
53
|
|
|
86
54
|
def detect_format(filename)
|
|
@@ -102,6 +70,11 @@ module Elkrb
|
|
|
102
70
|
end
|
|
103
71
|
end
|
|
104
72
|
|
|
73
|
+
def graph_from_hash(hash)
|
|
74
|
+
require_relative "../graph/graph"
|
|
75
|
+
Elkrb::Graph::Graph.from_hash(hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
105
78
|
def export_to_format(graph, format)
|
|
106
79
|
case format
|
|
107
80
|
when :json
|
|
@@ -120,10 +93,7 @@ module Elkrb
|
|
|
120
93
|
end
|
|
121
94
|
|
|
122
95
|
def write_output(content, filename)
|
|
123
|
-
|
|
124
|
-
FileUtils.mkdir_p(dir)
|
|
125
|
-
|
|
126
|
-
File.write(filename, content)
|
|
96
|
+
AtomicDestination.write(filename, content)
|
|
127
97
|
end
|
|
128
98
|
end
|
|
129
99
|
end
|