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
data/exe/elkrb
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
#
|
|
43
|
-
|
|
75
|
+
# Explicit flags go onto the root graph, where they outrank the file
|
|
76
|
+
graph = LayoutFlags.apply(graph_data, options)
|
|
44
77
|
|
|
45
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
162
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|