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
@@ -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
- puts "No graph files found in #{@directory}"
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
- # Summary
46
- puts ""
47
- puts "✓ Processed #{success_count} file(s) → #{@options[:output_dir]}"
48
- puts "⚠ #{error_count} error(s)" if error_count.positive?
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
- require "fileutils"
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
- puts "✓ Converted #{@file} → #{@options[:output]} (#{target_format})"
33
+ report_success(target_format)
31
34
  end
32
35
 
33
36
  private
34
37
 
35
- def load_any_format(file)
36
- raise ArgumentError, "File not found: #{file}" unless File.exist?(file)
37
-
38
- content = File.read(file)
39
- ext = File.extname(file).downcase
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 detect_and_parse(content)
60
- require_relative "../graph/graph"
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
- # Try ELKT
77
- begin
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
- dir = File.dirname(filename)
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