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
data/exe/elkrb CHANGED
@@ -1,8 +1,42 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "bundler/setup"
4
+ # Run from a source checkout, load THIS checkout. Without bundler/setup on the
5
+ # path there is nothing else pointing at ../lib, so `require "elkrb"` would
6
+ # quietly pick up an installed gem instead, or fail outright.
7
+ lib = File.expand_path("../lib", __dir__)
8
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
9
+
5
10
  require "elkrb"
6
11
  require "elkrb/cli"
7
12
 
8
- Elkrb::Cli.start(ARGV)
13
+ begin
14
+ Elkrb::Cli.start(ARGV)
15
+ rescue Elkrb::CommandFailed
16
+ # The command already tried to report itself; only the status is left to
17
+ # decide. Printing again here would duplicate the report in the usual case,
18
+ # and a report that failed to write once will not write now either.
19
+ exit 1
20
+ rescue Thor::Error => e
21
+ # A usage error (unknown command, missing required argument, wrong arity)
22
+ # never reaches a command body, so CommandFailed is not what Cli.start
23
+ # raises for it -- Cli.start's own #start override always raises this
24
+ # instead of exiting, matching the entry point that is allowed to call
25
+ # exit being THIS file, not the library class.
26
+ #
27
+ # THOR_DEBUG=1 asks for the backtrace, which is Thor's own contract for this
28
+ # rescue; and the report goes to $stderr directly, NOT through `warn`, which
29
+ # `ruby -W0` silences -- a usage error must still be reported there.
30
+ raise if ENV["THOR_DEBUG"] == "1"
31
+
32
+ $stderr.puts e.message # rubocop:disable Style/StderrPuts -- the cop's own
33
+ # suggestion (`warn`) is the bug: `ruby -W0` silences `warn` and would
34
+ # re-introduce the silent usage error this rescue exists to fix.
35
+ $stderr.puts Elkrb::Cli.usage_hint(ARGV) # rubocop:disable Style/StderrPuts
36
+ exit 1
37
+ rescue Errno::EPIPE
38
+ # A reader hanging up ends a SUCCESSFUL run (`elkrb layout big.json |
39
+ # head`), so this is the exit code a clean pipeline expects -- Cli.start's
40
+ # own #start override no longer calls `exit` for this itself.
41
+ exit 0
42
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ require_relative "errors"
7
+
8
+ module Elkrb
9
+ # Commits a single-file result only after its producer completes.
10
+ class AtomicDestination
11
+ class << self
12
+ def write(destination, content)
13
+ generate(destination) { |scratch| File.binwrite(scratch, content) }
14
+ end
15
+
16
+ def generate(destination, require_nonempty: false, &producer)
17
+ destination = File.path(destination)
18
+ directory = File.dirname(destination)
19
+ FileUtils.mkdir_p(directory)
20
+
21
+ basename = [".elkrb-", File.extname(destination)]
22
+ Tempfile.create(basename, directory) do |file|
23
+ File.chmod(destination_mode(destination), file.path)
24
+ commit(file, destination, require_nonempty, &producer)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def commit(file, destination, require_nonempty)
31
+ scratch = file.path
32
+ file.close
33
+ result = yield scratch
34
+ validate_output!(scratch) if require_nonempty
35
+ File.rename(scratch, destination)
36
+ result
37
+ end
38
+
39
+ def validate_output!(scratch)
40
+ return if File.file?(scratch) && File.size(scratch).positive?
41
+
42
+ raise Error, "Output producer reported success but produced no output"
43
+ end
44
+
45
+ def destination_mode(destination)
46
+ File.stat(destination).mode & 0o777
47
+ rescue Errno::ENOENT
48
+ 0o666 & ~File.umask
49
+ end
50
+ end
51
+ end
52
+ private_constant :AtomicDestination
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pure Ruby port of the Eclipse Layout Kernel. This file reopens the namespace
4
+ # only to hang an internal helper off it; the public API lives in lib/elkrb.rb.
5
+ module Elkrb
6
+ # Only the write that emits the RESULT may decide the exit status. Every
7
+ # other write -- a progress line, a failure report, anything printed
8
+ # before or alongside the result -- is best-effort.
9
+ #
10
+ # Such a write can raise for reasons that have nothing to do with what it
11
+ # is reporting: the reader hung up (Errno::EPIPE), or the text holds bytes
12
+ # the stream cannot encode (ArgumentError). Either used to escape in place
13
+ # of whatever came after it -- and because Thor rescues EPIPE and calls
14
+ # exit(true), a caller lost both its result (or its error) AND its
15
+ # process, silently, at exit 0.
16
+ #
17
+ # This module used to be named FailureReport and cover only a report
18
+ # printed just before a raise. That was too narrow: a `--verbose` progress
19
+ # line is not a failure report, so it never went through `attempt`, and a
20
+ # dead stdout during a verbose run had the exact same silent-exit-0 defect
21
+ # this module exists to prevent. Any write that is not the result write
22
+ # must go through `attempt`, whether or not a raise follows it -- UNLESS
23
+ # the write cannot raise in the first place. `Kernel#warn` is the one
24
+ # exception in this codebase, for the way stderr actually goes dead here:
25
+ # a reader closing its end of a real pipe leaves `$stderr` pointing at the
26
+ # SAME IO object with a now-broken file descriptor (`$stderr.reopen`, or a
27
+ # subprocess's inherited fd closing under it) -- and measured directly,
28
+ # `warn` does not raise there, unlike `$stderr.puts`/`$stderr.write`. It
29
+ # does raise if `$stderr` itself is reassigned to a different broken IO
30
+ # object (`$stderr = other_io`), which is a test-harness shape, not
31
+ # anything a closed pipe does to a running process. `warn` call sites in
32
+ # this codebase are exempt by construction for the real failure mode, not
33
+ # by oversight.
34
+ module BestEffortWrite
35
+ module_function
36
+
37
+ # Returns the block's value, or nil if the write raised. Nothing reads
38
+ # the value today -- either execution continues past a swallowed write,
39
+ # or a raise that follows the call site is what matters.
40
+ def attempt
41
+ yield
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ end
46
+ # Internal. Reachable by the bare name from anywhere lexically inside
47
+ # Elkrb, which is every call site there will be.
48
+ private_constant :BestEffortWrite
49
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ require_relative "../options/registry"
6
+
7
+ module Elkrb
8
+ class Cli < Thor
9
+ # The flags that become layout options, declared once for every command
10
+ # that lays out a graph. `extend` it into the Thor class and call
11
+ # `layout_flags` directly above the command's `def`: Thor attaches
12
+ # `option` declarations to the next method defined, so the call has to
13
+ # sit where the individual `option` lines would.
14
+ #
15
+ # LayoutFlags.apply turns the parsed flags into root-graph options. Enum
16
+ # values in the help text are read from the registry when the class is
17
+ # defined, so a value added there reaches `--help` without an edit here.
18
+ module LayoutFlagOptions
19
+ # No Thor default: an absent --algorithm must stay nil so the graph's
20
+ # own elk.algorithm can be read before falling back to layered.
21
+ ALGORITHM_DESC = "Layout algorithm to use (default: the graph's " \
22
+ "own elk.algorithm, else layered)"
23
+
24
+ def layout_flags
25
+ option :algorithm, type: :string, desc: ALGORITHM_DESC
26
+ option :direction, type: :string, desc: enum_desc(
27
+ "Layout direction", "elk.direction",
28
+ "; applied by layered and mrtree algorithms"
29
+ )
30
+ option :edge_routing, type: :string,
31
+ desc: enum_desc("Edge routing", "elk.edgeRouting")
32
+ numeric_flags
33
+ end
34
+
35
+ private
36
+
37
+ def numeric_flags
38
+ option :spacing, type: :numeric, desc: "Node spacing"
39
+ option :layer_spacing, type: :numeric,
40
+ desc: "Layer spacing (for layered algorithm)"
41
+ %w[top bottom left right].each do |side|
42
+ option :"padding_#{side}", type: :numeric,
43
+ desc: "#{side.capitalize} padding"
44
+ end
45
+ end
46
+
47
+ # @param id [String] a canonical registry id with an enumerated value list
48
+ def enum_desc(label, id, suffix = "")
49
+ values = Options::Registry.all.fetch(id).fetch(:values)
50
+ "#{label}, one of #{values.join(', ')}#{suffix}"
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/elkrb/cli.rb CHANGED
@@ -4,61 +4,97 @@ require "thor"
4
4
  require "json"
5
5
  require "yaml"
6
6
 
7
+ require_relative "errors"
8
+ require_relative "atomic_destination"
9
+ require_relative "best_effort_write"
10
+ require_relative "layout_flags"
11
+ require_relative "cli/layout_flag_options"
12
+ require_relative "options/resolver"
13
+
7
14
  module Elkrb
8
15
  # Command-line interface for elkrb
9
16
  #
10
17
  # Provides commands for laying out graphs from the command line.
11
- # Supports JSON and YAML input/output formats.
18
+ # Supports JSON and YAML input/output formats, with an ELKT fallback
19
+ # for files whose extension isn't recognized.
12
20
  class Cli < Thor
21
+ # Do not restore Thor::Base#start's rescues here. `rescue Thor::Error`
22
+ # either exits the process or swallows the error depending on
23
+ # `exit_on_failure?`, and `rescue Errno::EPIPE; exit(true)` exits
24
+ # unconditionally with no config to suppress it -- so a reader hanging
25
+ # up on `layout | head` would kill a library caller. Dispatching
26
+ # directly lets both propagate as themselves; exe/elkrb, the one entry
27
+ # point allowed to exit, turns them into exit codes.
28
+ def self.start(given_args = ARGV, config = {})
29
+ config[:shell] ||= Thor::Base.shell.new
30
+ dispatch(nil, given_args.dup, nil, config)
31
+ end
32
+
33
+ # Never reached: the override above calls #dispatch directly rather
34
+ # than Thor::Base#start, so nothing here consults this. Thor emits a
35
+ # deprecation warning if it is left undefined, so it stays, set to the
36
+ # value that matches this class actually being a library.
37
+ def self.exit_on_failure? = false
38
+
39
+ # The line that follows a usage error: help for the command the user
40
+ # named, else for the CLI as a whole.
41
+ #
42
+ # @param argv [Array<String>] the arguments the CLI was started with
43
+ # @return [String]
44
+ def self.usage_hint(argv)
45
+ name = argv.first
46
+ known = name && all_commands.key?(normalize_command_name(name))
47
+ known ? "Try: elkrb help #{name}" : "Try: elkrb help"
48
+ end
49
+
50
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
51
+ private_constant :YAML_EXTENSIONS
52
+
53
+ extend LayoutFlagOptions
54
+
55
+ # A mistyped flag is an error, not a positional argument: without this
56
+ # `elkrb layout --bogus` tries to read a file named "--bogus".
57
+ check_unknown_options!
58
+
13
59
  class_option :verbose, type: :boolean, default: false,
14
60
  desc: "Enable verbose output"
15
61
 
16
- desc "layout FILE", "Layout a graph from a JSON or YAML file"
17
- option :algorithm, type: :string, default: "layered",
18
- desc: "Layout algorithm to use"
62
+ desc "layout FILE", "Layout a graph from a JSON, YAML, or ELKT file"
19
63
  option :output, type: :string, aliases: "-o",
20
64
  desc: "Output file (default: stdout)"
21
- option :format, type: :string, default: "json",
22
- enum: %w[json yaml],
23
- desc: "Output format"
24
- option :spacing, type: :numeric,
25
- desc: "Node spacing"
26
- option :layer_spacing, type: :numeric,
27
- desc: "Layer spacing (for layered algorithm)"
28
- option :padding_top, type: :numeric,
29
- desc: "Top padding"
30
- option :padding_bottom, type: :numeric,
31
- desc: "Bottom padding"
32
- option :padding_left, type: :numeric,
33
- desc: "Left padding"
34
- option :padding_right, type: :numeric,
35
- desc: "Right padding"
65
+ option :format, type: :string, enum: %w[json yaml],
66
+ desc: "Output format (default: from the --output " \
67
+ "extension, .yml/.yaml for YAML, else json)"
68
+ layout_flags
36
69
  def layout(file)
37
70
  verbose_output "Loading graph from #{file}..."
38
71
 
39
72
  # Read input file
40
73
  graph_data = read_input_file(file)
41
74
 
42
- # Build layout options
43
- layout_options = build_layout_options
75
+ # Explicit flags go onto the root graph, where they outrank the file
76
+ graph = LayoutFlags.apply(graph_data, options)
44
77
 
45
- verbose_output "Using algorithm: #{layout_options[:algorithm]}"
78
+ algorithm = Options::Resolver.new.get("elk.algorithm", graph)
79
+ verbose_output "Using algorithm: #{algorithm}"
46
80
 
47
81
  # Perform layout
48
- result = Layout::LayoutEngine.layout(graph_data, layout_options)
82
+ result = Layout::LayoutEngine.layout(graph, {})
49
83
 
50
84
  # Output result
51
85
  output_result(result)
52
86
 
53
87
  verbose_output "Layout complete!"
54
88
  rescue StandardError => e
55
- error_output "Error: #{e.message}"
56
- exit 1
89
+ fail_command(e, "layout")
57
90
  end
58
91
 
59
92
  desc "algorithms", "List available layout algorithms"
93
+ option :json, type: :boolean, default: false,
94
+ desc: "Print the list as JSON on stdout"
60
95
  def algorithms
61
96
  algos = Layout::LayoutEngine.known_layout_algorithms
97
+ return say(json_for_algorithms(algos)) if options[:json]
62
98
 
63
99
  say "Available Layout Algorithms:", :green
64
100
  say ""
@@ -73,27 +109,31 @@ module Elkrb
73
109
  end
74
110
  end
75
111
 
112
+ desc "options [ALGORITHM]",
113
+ "List layout options, or those one algorithm supports"
114
+ option :json, type: :boolean, default: false,
115
+ desc: "Print the options as JSON on stdout"
116
+ def options_list(algorithm = nil)
117
+ require_relative "commands/options_command"
118
+ Commands::OptionsCommand.new(algorithm, options).run
119
+ rescue StandardError => e
120
+ fail_command(e, "options")
121
+ end
122
+ map "options" => :options_list
123
+
76
124
  desc "diagram FILE", "Create diagram from ELK graph file"
77
- option :algorithm, type: :string, default: "layered",
78
- desc: "Layout algorithm to use"
79
- option :direction, type: :string,
80
- desc: "Layout direction (e.g., DOWN, RIGHT)"
81
- option :spacing, type: :numeric,
82
- desc: "Node spacing"
83
- option :edge_routing, type: :string,
84
- desc: "Edge routing strategy"
85
125
  option :output, type: :string, aliases: "-o", required: true,
86
126
  desc: "Output file path"
87
127
  option :format, type: :string,
88
128
  desc: "Output format (auto-detected from extension)"
89
129
  option :preview, type: :boolean, default: false,
90
130
  desc: "Open result in default viewer"
131
+ layout_flags
91
132
  def diagram(file)
92
133
  require_relative "commands/diagram_command"
93
134
  Commands::DiagramCommand.new(file, options).run
94
135
  rescue StandardError => e
95
- error_output "Error: #{e.message}"
96
- exit 1
136
+ fail_command(e, "diagram")
97
137
  end
98
138
 
99
139
  desc "convert FILE", "Convert between formats (JSON/YAML/DOT/ELKT)"
@@ -105,8 +145,7 @@ module Elkrb
105
145
  require_relative "commands/convert_command"
106
146
  Commands::ConvertCommand.new(file, options).run
107
147
  rescue StandardError => e
108
- error_output "Error: #{e.message}"
109
- exit 1
148
+ fail_command(e, "convert")
110
149
  end
111
150
 
112
151
  desc "render DOT_FILE", "Render DOT to image (requires Graphviz)"
@@ -120,8 +159,7 @@ module Elkrb
120
159
  require_relative "commands/render_command"
121
160
  Commands::RenderCommand.new(dot_file, options).run
122
161
  rescue StandardError => e
123
- error_output "Error: #{e.message}"
124
- exit 1
162
+ fail_command(e, "render")
125
163
  end
126
164
 
127
165
  desc "validate FILE", "Validate ELK graph structure"
@@ -131,8 +169,7 @@ module Elkrb
131
169
  require_relative "commands/validate_command"
132
170
  Commands::ValidateCommand.new(file, options).run
133
171
  rescue StandardError => e
134
- error_output "Error: #{e.message}"
135
- exit 1
172
+ fail_command(e, "validate")
136
173
  end
137
174
 
138
175
  desc "batch DIR", "Process multiple files in a directory"
@@ -140,14 +177,12 @@ module Elkrb
140
177
  desc: "Output directory for generated files"
141
178
  option :format, type: :string, default: "svg",
142
179
  desc: "Output format for all files"
143
- option :algorithm, type: :string, default: "layered",
144
- desc: "Layout algorithm to use"
180
+ layout_flags
145
181
  def batch(directory)
146
182
  require_relative "commands/batch_command"
147
183
  Commands::BatchCommand.new(directory, options).run
148
184
  rescue StandardError => e
149
- error_output "Error: #{e.message}"
150
- exit 1
185
+ fail_command(e, "batch")
151
186
  end
152
187
 
153
188
  desc "version", "Show elkrb version"
@@ -158,47 +193,28 @@ module Elkrb
158
193
  private
159
194
 
160
195
  def read_input_file(file)
161
- require_relative "graph/graph"
162
- content = File.read(file)
163
-
164
- case File.extname(file).downcase
165
- when ".json"
166
- Elkrb::Graph::Graph.from_json(content)
167
- when ".yml", ".yaml"
168
- Elkrb::Graph::Graph.from_yaml(content)
169
- else
170
- # Try JSON first, then YAML
171
- begin
172
- Elkrb::Graph::Graph.from_json(content)
173
- rescue JSON::ParserError
174
- Elkrb::Graph::Graph.from_yaml(content)
175
- end
176
- end
196
+ require_relative "format_sniffer"
197
+ Elkrb::FormatSniffer.read(File.read(file), File.extname(file).downcase)
177
198
  end
178
199
 
179
- def build_layout_options
180
- opts = { algorithm: options[:algorithm] }
200
+ # --format wins; without it the --output extension decides, because
201
+ # `--output result.yml` asking for YAML is the one reading that cannot
202
+ # surprise anyone.
203
+ def output_format
204
+ return options[:format] if options[:format]
181
205
 
182
- # Add spacing options
183
- opts[:spacing_node_node] = options[:spacing] if options[:spacing]
184
- opts[:layer_spacing] = options[:layer_spacing] if options[:layer_spacing]
185
-
186
- # Add padding options
187
- if options[:padding_top] || options[:padding_bottom] ||
188
- options[:padding_left] || options[:padding_right]
189
- opts[:padding] = {
190
- top: options[:padding_top] || 12,
191
- bottom: options[:padding_bottom] || 12,
192
- left: options[:padding_left] || 12,
193
- right: options[:padding_right] || 12,
194
- }
195
- end
206
+ extension = File.extname(options[:output].to_s).downcase
207
+ YAML_EXTENSIONS.include?(extension) ? "yaml" : "json"
208
+ end
196
209
 
197
- opts
210
+ def json_for_algorithms(algos)
211
+ keys = %i[id name description category supports_hierarchy
212
+ supported_options]
213
+ JSON.generate("algorithms" => algos.map { |algo| algo.slice(*keys) })
198
214
  end
199
215
 
200
216
  def output_result(result)
201
- output = case options[:format]
217
+ output = case output_format
202
218
  when "yaml"
203
219
  result.to_yaml
204
220
  else
@@ -206,19 +222,76 @@ module Elkrb
206
222
  end
207
223
 
208
224
  if options[:output]
209
- File.write(options[:output], output)
225
+ AtomicDestination.write(options[:output], output)
210
226
  verbose_output "Output written to #{options[:output]}"
211
227
  else
212
228
  say output
213
229
  end
214
230
  end
215
231
 
232
+ # Best-effort: this is a progress line, not the result. On a closed
233
+ # stderr it must not take the command down with it -- see
234
+ # Elkrb::BestEffortWrite for why a dead stream here used to mean
235
+ # SystemExit(0) with the real work never attempted.
236
+ #
237
+ # RC10: a progress line is not the result, so it never belongs on
238
+ # stdout next to the JSON. `say_error` is Thor's stderr counterpart to
239
+ # `say` and keeps the same colour helper.
216
240
  def verbose_output(message)
217
- say message, :yellow if options[:verbose]
241
+ return unless options[:verbose]
242
+
243
+ BestEffortWrite.attempt { say_error message, :yellow }
218
244
  end
219
245
 
220
246
  def error_output(message)
221
- say message, :red
247
+ # Kernel#warn is silent when warnings are off. Measured on ruby 3.4.8:
248
+ # `ruby -W0 -e 'warn "MSG"'` prints nothing, `ruby -W0 -e '$stderr.puts
249
+ # "MSG"'` prints MSG. Every command's rescue reports through here, so
250
+ # this is the one line that has to survive -W0; the cop's suggestion
251
+ # would take it away. It does not rescue the detail lines the commands
252
+ # emit with warn -- those already vanish under -W0.
253
+ # rubocop:disable Style/StderrPuts
254
+ $stderr.puts message
255
+ # rubocop:enable Style/StderrPuts
256
+ rescue Errno::EPIPE
257
+ # Same reason as verbose_output. A closed stderr must not turn a real
258
+ # failure into a different one, or mask the exit status the caller
259
+ # needs to see.
260
+ nil
261
+ end
262
+
263
+ # Keep the CommandFailed re-raise: ValidateCommand#run prints its own list
264
+ # of errors, so reporting it again here appends a second, redundant
265
+ # `Error: ...` line AFTER that list on every failed `elkrb validate`.
266
+ #
267
+ # Keep the Errno::EPIPE re-raise: a reader hanging up ends a SUCCESSFUL
268
+ # run (`elkrb layout big.json | head`), and Thor turns a re-raised EPIPE
269
+ # into exit 0. Wrap it instead and that clean pipeline starts exiting 1.
270
+ #
271
+ # The report is best-effort; see Elkrb::BestEffortWrite for why, and wrap
272
+ # any new non-result write -- a report, a progress line, anything that
273
+ # is not the write emitting the command's actual output -- the same way.
274
+ def fail_command(error, command)
275
+ hint = failure_hint(error, command)
276
+ raise error if error.is_a?(Errno::EPIPE)
277
+
278
+ if error.is_a?(CommandFailed)
279
+ BestEffortWrite.attempt { error_output hint }
280
+ raise error
281
+ end
282
+
283
+ message = error.message
284
+ BestEffortWrite.attempt do
285
+ error_output "Error: #{message}"
286
+ error_output hint
287
+ end
288
+ raise CommandFailed, message
289
+ end
290
+
291
+ def failure_hint(error, command)
292
+ return "Try: elkrb algorithms" if error.is_a?(AlgorithmNotFoundError)
293
+
294
+ "Try: elkrb help #{command}"
222
295
  end
223
296
  end
224
297
  end