graphomaton 1.0.0 → 1.1.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.md +74 -8
- data/README.md +426 -44
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +255 -18
- data/lib/graphomaton/exporters/mermaid.rb +705 -25
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +250 -13
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2775 -231
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +11 -4
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2869 -54
- data/sig/graphomaton.rbs +127 -0
- metadata +34 -24
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -30
- data/sample/complex.rb +0 -32
- data/sample/long_names.rb +0 -20
- data/sample/nfa.rb +0 -28
- data/sample/skip_states.rb +0 -23
- data/spec/exporters/dot_spec.rb +0 -146
- data/spec/exporters/mermaid_spec.rb +0 -154
- data/spec/exporters/plantuml_spec.rb +0 -144
- data/spec/exporters/svg_spec.rb +0 -314
- data/spec/graphomaton_edge_cases_spec.rb +0 -322
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative '../graphomaton'
|
|
5
|
+
require_relative 'cli/config'
|
|
6
|
+
|
|
7
|
+
class Graphomaton
|
|
8
|
+
class CLI
|
|
9
|
+
EXIT_SUCCESS = 0
|
|
10
|
+
EXIT_USAGE = 2
|
|
11
|
+
EXIT_INPUT = 3
|
|
12
|
+
EXIT_VALIDATION = 4
|
|
13
|
+
EXIT_LAYOUT = 5
|
|
14
|
+
EXIT_EXPORT = 6
|
|
15
|
+
EXIT_SECURITY = 7
|
|
16
|
+
COMMANDS = %w[render validate themes list doctor completion man].freeze
|
|
17
|
+
COMPLETION_SHELLS = %w[bash zsh fish].freeze
|
|
18
|
+
COMPLETION_WORDS = %w[
|
|
19
|
+
render validate themes list doctor completion man formats layouts converters
|
|
20
|
+
--input --input-format --output --format --config --no-clobber --force --validate
|
|
21
|
+
--no-validate --diagnostics --fail-on-warning --strict-semantics --layout-warnings
|
|
22
|
+
--width --height --theme --theme-file --layout --direction --fit --padding
|
|
23
|
+
--node-spacing --rank-spacing --force-iterations --layout-seed --graphviz-command
|
|
24
|
+
--max-metadata-depth --max-label-length --max-group-depth
|
|
25
|
+
--responsive --state-radius --state-shape --edge-style --wrap-labels --title
|
|
26
|
+
--description --cdn --offline --inline-mermaid --inline-mathjax --self-contained
|
|
27
|
+
--nonce --csp --csp-policy --mermaid-sha256 --mathjax-sha256 --version --help
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
31
|
+
@stdin = stdin
|
|
32
|
+
@stdout = stdout
|
|
33
|
+
@stderr = stderr
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(arguments = ARGV)
|
|
37
|
+
@debug = arguments.include?('--debug')
|
|
38
|
+
catch(:graphomaton_cli_exit) do
|
|
39
|
+
execute(arguments.dup)
|
|
40
|
+
EXIT_SUCCESS
|
|
41
|
+
end
|
|
42
|
+
rescue OptionParser::ParseError => e
|
|
43
|
+
report_exception(e)
|
|
44
|
+
EXIT_USAGE
|
|
45
|
+
rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
|
|
46
|
+
report_exception(e)
|
|
47
|
+
EXIT_INPUT
|
|
48
|
+
rescue Graphomaton::SecurityError => e
|
|
49
|
+
report_exception(e)
|
|
50
|
+
EXIT_SECURITY
|
|
51
|
+
rescue Graphomaton::LayoutError => e
|
|
52
|
+
report_exception(e)
|
|
53
|
+
EXIT_LAYOUT
|
|
54
|
+
rescue Graphomaton::Error => e
|
|
55
|
+
report_exception(e)
|
|
56
|
+
EXIT_EXPORT
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def halt(status)
|
|
62
|
+
throw :graphomaton_cli_exit, status
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def warn(message)
|
|
66
|
+
@stderr.puts(message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def puts(message)
|
|
70
|
+
@stdout.puts(message)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def report_exception(error, prefix: nil)
|
|
74
|
+
message = @debug ? error.full_message : error.message
|
|
75
|
+
message = "#{prefix}: #{message}" if prefix
|
|
76
|
+
warn(message)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_theme_file(path)
|
|
80
|
+
File.open(path, 'rb') do |theme_input|
|
|
81
|
+
case File.extname(path).downcase
|
|
82
|
+
when '.json'
|
|
83
|
+
Graphomaton.theme_from_json(theme_input)
|
|
84
|
+
when '.yml', '.yaml'
|
|
85
|
+
Graphomaton.theme_from_yaml(theme_input)
|
|
86
|
+
else
|
|
87
|
+
raise ArgumentError, 'Theme file must use .json, .yml, or .yaml extension'
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
|
|
91
|
+
report_exception(e, prefix: 'Theme input error')
|
|
92
|
+
halt(EXIT_INPUT)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_automaton(source, format, limits)
|
|
96
|
+
case format.to_s.delete_prefix('.').downcase
|
|
97
|
+
when 'json'
|
|
98
|
+
Graphomaton.from_json(source, **limits)
|
|
99
|
+
when 'yml', 'yaml'
|
|
100
|
+
Graphomaton.from_yaml(source, **limits)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, 'Input format must be json, yml, or yaml'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def load_automaton(path, input_format:, limits:)
|
|
107
|
+
if path == '-'
|
|
108
|
+
payload = @stdin.read(limits.fetch(:max_input_bytes) + 1)
|
|
109
|
+
detected_format = input_format || (payload.lstrip.start_with?('{', '[') ? :json : :yaml)
|
|
110
|
+
return parse_automaton(payload, detected_format, limits)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
extension = File.extname(path)
|
|
114
|
+
if input_format.nil? && !%w[.json .yml .yaml].include?(extension.downcase)
|
|
115
|
+
raise ArgumentError, 'Input file must use .json, .yml, or .yaml extension'
|
|
116
|
+
end
|
|
117
|
+
format = input_format || extension
|
|
118
|
+
File.open(path, 'rb') { |source| parse_automaton(source, format, limits) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_cli_numeric_options!(options)
|
|
122
|
+
positive = %i[
|
|
123
|
+
width height scale timeout max_output_bytes max_input_bytes max_states max_transitions
|
|
124
|
+
max_metadata_depth max_label_length max_group_depth
|
|
125
|
+
state_radius min_state_radius max_state_radius
|
|
126
|
+
state_stroke_width transition_stroke_width arrow_size initial_arrow_length final_arrow_length
|
|
127
|
+
]
|
|
128
|
+
nonnegative = %i[
|
|
129
|
+
padding node_spacing rank_spacing force_iterations max_transition_label_width
|
|
130
|
+
max_state_label_width label_padding label_radius
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
positive.each do |name|
|
|
134
|
+
value = options[name]
|
|
135
|
+
next if value.nil?
|
|
136
|
+
|
|
137
|
+
valid = value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
|
|
138
|
+
raise OptionParser::InvalidArgument, "--#{name.to_s.tr('_', '-')} must be positive and finite" unless valid
|
|
139
|
+
end
|
|
140
|
+
nonnegative.each do |name|
|
|
141
|
+
value = options[name]
|
|
142
|
+
next if value.nil?
|
|
143
|
+
|
|
144
|
+
valid = value.is_a?(Numeric) && value.real? && value.to_f.finite? && value >= 0
|
|
145
|
+
raise OptionParser::InvalidArgument, "--#{name.to_s.tr('_', '-')} must be non-negative and finite" unless valid
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_format_options!(options, format)
|
|
150
|
+
svg_backed = %i[svg png pdf webp]
|
|
151
|
+
converted = %i[png pdf webp]
|
|
152
|
+
support = {}
|
|
153
|
+
%i[
|
|
154
|
+
layout_warnings layout fit padding node_spacing rank_spacing force_iterations layout_seed
|
|
155
|
+
graphviz_command auto_density_spacing initial_position final_position responsive state_radius
|
|
156
|
+
auto_state_radius min_state_radius max_state_radius state_stroke_width transition_stroke_width
|
|
157
|
+
state_shape edge_style arrow_shape arrow_size state_effect font_family state_font_weight
|
|
158
|
+
transition_font_weight preserve_manual_positions auto_size xml_declaration pretty minify
|
|
159
|
+
css_variables embed_styles svg_id wrap max_transition_label_width state_wrap
|
|
160
|
+
max_state_label_width label_tooltips html_tooltips sort_labels rotate_labels label_padding
|
|
161
|
+
label_radius label_border label_background initial_arrow_length initial_arrow_label
|
|
162
|
+
final_arrow_length final_arrow_label show_final_arrows scc_groups fold_groups
|
|
163
|
+
highlight_unreachable unreachable_zone highlight_dead_states highlight_initial_state
|
|
164
|
+
highlight_final_states highlight_transitions loop_position merge_parallel_transitions description
|
|
165
|
+
].each { |name| support[name] = svg_backed }
|
|
166
|
+
support[:theme] = svg_backed + %i[html dot plantuml]
|
|
167
|
+
support[:theme_file] = svg_backed + %i[dot plantuml]
|
|
168
|
+
support[:direction] = svg_backed + %i[html mermaid dot plantuml]
|
|
169
|
+
support[:title] = svg_backed + [:html]
|
|
170
|
+
%i[converter timeout max_output_bytes].each { |name| support[name] = converted }
|
|
171
|
+
support[:scale] = [:png]
|
|
172
|
+
%i[
|
|
173
|
+
cdn offline inline_mermaid lang show_source pan_zoom mathjax mathjax_cdn inline_mathjax
|
|
174
|
+
self_contained nonce csp mermaid_sha256 mathjax_sha256
|
|
175
|
+
].each { |name| support[name] = [:html] }
|
|
176
|
+
support[:notes] = %i[html mermaid plantuml]
|
|
177
|
+
support[:class_defs] = %i[html mermaid]
|
|
178
|
+
support[:rank_constraints] = [:dot]
|
|
179
|
+
|
|
180
|
+
unsupported = support.each_key.select { |name| options.key?(name) && !support.fetch(name).include?(format) }
|
|
181
|
+
unless unsupported.empty?
|
|
182
|
+
flags = unsupported.map { |name| "--#{name.to_s.tr('_', '-')}" }.join(', ')
|
|
183
|
+
raise OptionParser::InvalidArgument, "#{flags} not supported for #{format} output"
|
|
184
|
+
end
|
|
185
|
+
if format == :html && options[:theme].is_a?(Hash)
|
|
186
|
+
raise OptionParser::InvalidArgument, 'custom theme mappings are not supported for html output'
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def extract_command(arguments)
|
|
191
|
+
return :render unless COMMANDS.include?(arguments.first)
|
|
192
|
+
|
|
193
|
+
arguments.shift.to_sym
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def option_value(arguments, long, short = nil)
|
|
197
|
+
arguments.each_with_index do |argument, index|
|
|
198
|
+
return argument.split('=', 2).last if argument.start_with?("#{long}=")
|
|
199
|
+
return arguments[index + 1] if argument == long || (short && argument == short)
|
|
200
|
+
end
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_hint(arguments)
|
|
205
|
+
explicit = option_value(arguments, '--format', '-f')
|
|
206
|
+
return Graphomaton::EXPORTERS.resolve(explicit) if explicit
|
|
207
|
+
|
|
208
|
+
output = option_value(arguments, '--output', '-o')
|
|
209
|
+
return Graphomaton::EXPORTERS.resolve(File.extname(output)) if output && output != '-'
|
|
210
|
+
|
|
211
|
+
arguments.reject { |argument| argument.start_with?('-') }.reverse_each do |candidate|
|
|
212
|
+
return Graphomaton::EXPORTERS.resolve(File.extname(candidate))
|
|
213
|
+
rescue ArgumentError
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
nil
|
|
217
|
+
rescue ArgumentError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def config_path(arguments)
|
|
222
|
+
explicit = option_value(arguments, '--config')
|
|
223
|
+
return [explicit, true] if explicit
|
|
224
|
+
|
|
225
|
+
environment_path = ENV['GRAPHOMATON_CONFIG']
|
|
226
|
+
return [environment_path, true] if environment_path && !environment_path.empty?
|
|
227
|
+
|
|
228
|
+
[Config::DEFAULT_PATH, false]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def environment_options
|
|
232
|
+
mappings = {
|
|
233
|
+
'GRAPHOMATON_FORMAT' => [:format, ->(value) { value.to_sym }],
|
|
234
|
+
'GRAPHOMATON_THEME' => [:theme, ->(value) { value.to_sym }],
|
|
235
|
+
'GRAPHOMATON_LAYOUT' => [:layout, ->(value) { value.to_sym }],
|
|
236
|
+
'GRAPHOMATON_WIDTH' => [:width, ->(value) { Integer(value, 10) }],
|
|
237
|
+
'GRAPHOMATON_HEIGHT' => [:height, ->(value) { Integer(value, 10) }]
|
|
238
|
+
}
|
|
239
|
+
mappings.each_with_object({}) do |(environment_name, (option_name, parser)), options|
|
|
240
|
+
value = ENV[environment_name]
|
|
241
|
+
options[option_name] = parser.call(value) if value && !value.empty?
|
|
242
|
+
end
|
|
243
|
+
rescue ArgumentError => e
|
|
244
|
+
raise OptionParser::InvalidArgument, "Invalid Graphomaton environment option: #{e.message}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def execute_list(arguments)
|
|
248
|
+
target = arguments.shift
|
|
249
|
+
unless arguments.empty? || target.nil?
|
|
250
|
+
warn "Unexpected arguments: #{arguments.join(' ')}"
|
|
251
|
+
halt(EXIT_USAGE)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
values = case target
|
|
255
|
+
when 'formats' then Graphomaton::EXPORTERS.formats
|
|
256
|
+
when 'layouts' then Graphomaton::LAYOUT_OPTIONS
|
|
257
|
+
when 'themes' then Graphomaton::Theme.available_names
|
|
258
|
+
when 'converters' then %w[rsvg magick convert]
|
|
259
|
+
else
|
|
260
|
+
warn 'Usage: graphomaton list formats|layouts|themes|converters'
|
|
261
|
+
halt(EXIT_USAGE)
|
|
262
|
+
end
|
|
263
|
+
puts values.join("\n")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def execute_doctor(arguments)
|
|
267
|
+
unless arguments.empty?
|
|
268
|
+
warn "Unexpected arguments: #{arguments.join(' ')}"
|
|
269
|
+
halt(EXIT_USAGE)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
checks = {
|
|
273
|
+
graphomaton: Graphomaton::VERSION,
|
|
274
|
+
ruby: RUBY_DESCRIPTION,
|
|
275
|
+
graphviz: renderer_health('dot', '-V'),
|
|
276
|
+
rsvg: renderer_health('rsvg-convert', '--version'),
|
|
277
|
+
imagemagick: renderer_health('magick', '-version', fallback: 'convert'),
|
|
278
|
+
mermaid: renderer_health('mmdc', '--version'),
|
|
279
|
+
plantuml: renderer_health('plantuml', '-version'),
|
|
280
|
+
png: Graphomaton::Exporters::Png.available? ? 'available' : 'missing',
|
|
281
|
+
pdf: Graphomaton::Exporters::Pdf.available? ? 'available' : 'missing',
|
|
282
|
+
webp: Graphomaton::Exporters::Webp.available? ? 'available' : 'missing'
|
|
283
|
+
}
|
|
284
|
+
checks.each { |name, value| puts "#{name}: #{value}" }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def execute_themes(arguments)
|
|
288
|
+
unless arguments.empty?
|
|
289
|
+
warn "Unexpected arguments: #{arguments.join(' ')}"
|
|
290
|
+
halt(EXIT_USAGE)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
puts Graphomaton::Theme.available_names.join("\n")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def execute_completion(arguments)
|
|
297
|
+
shell = arguments.shift
|
|
298
|
+
unless COMPLETION_SHELLS.include?(shell) && arguments.empty?
|
|
299
|
+
warn "Usage: graphomaton completion #{COMPLETION_SHELLS.join('|')}"
|
|
300
|
+
halt(EXIT_USAGE)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
words = COMPLETION_WORDS.join(' ')
|
|
304
|
+
output = case shell
|
|
305
|
+
when 'bash'
|
|
306
|
+
<<~BASH
|
|
307
|
+
_graphomaton_completion() {
|
|
308
|
+
COMPREPLY=( $(compgen -W '#{words}' -- "${COMP_WORDS[COMP_CWORD]}") )
|
|
309
|
+
}
|
|
310
|
+
complete -F _graphomaton_completion graphomaton
|
|
311
|
+
BASH
|
|
312
|
+
when 'zsh'
|
|
313
|
+
<<~ZSH
|
|
314
|
+
#compdef graphomaton
|
|
315
|
+
_arguments '*:graphomaton command or option:(#{words})'
|
|
316
|
+
ZSH
|
|
317
|
+
when 'fish'
|
|
318
|
+
COMPLETION_WORDS.map { |word| "complete -c graphomaton -f -a '#{word}'" }.join("\n") + "\n"
|
|
319
|
+
end
|
|
320
|
+
@stdout.write(output)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def execute_man(arguments)
|
|
324
|
+
unless arguments.empty?
|
|
325
|
+
warn "Unexpected arguments: #{arguments.join(' ')}"
|
|
326
|
+
halt(EXIT_USAGE)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
@stdout.write <<~MANPAGE
|
|
330
|
+
.TH GRAPHOMATON 1 "2026-08-12" "Graphomaton #{Graphomaton::VERSION}" "User Commands"
|
|
331
|
+
.SH NAME
|
|
332
|
+
graphomaton \- validate, analyze, and render finite-state machines
|
|
333
|
+
.SH SYNOPSIS
|
|
334
|
+
.B graphomaton
|
|
335
|
+
[render] -i INPUT -o OUTPUT [options]
|
|
336
|
+
.br
|
|
337
|
+
.B graphomaton validate
|
|
338
|
+
INPUT [--diagnostics text|json]
|
|
339
|
+
.SH COMMANDS
|
|
340
|
+
render, validate, themes, list, doctor, completion, and man.
|
|
341
|
+
.SH EXIT STATUS
|
|
342
|
+
0 success; 2 usage; 3 input; 4 validation; 5 layout; 6 export; 7 security.
|
|
343
|
+
.SH FILES
|
|
344
|
+
.I .graphomaton.yml
|
|
345
|
+
supplies defaults overridden by environment variables and command-line options.
|
|
346
|
+
.SH SEE ALSO
|
|
347
|
+
https://github.com/ydah/graphomaton
|
|
348
|
+
MANPAGE
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def renderer_health(command, *version_arguments, fallback: nil)
|
|
352
|
+
path = Graphomaton::ProcessRunner.which(command)
|
|
353
|
+
path ||= Graphomaton::ProcessRunner.which(fallback) if fallback
|
|
354
|
+
return 'missing' unless path
|
|
355
|
+
|
|
356
|
+
stdout, stderr, status = Graphomaton::ProcessRunner.capture3(
|
|
357
|
+
path,
|
|
358
|
+
*version_arguments,
|
|
359
|
+
timeout: 3,
|
|
360
|
+
max_stdout_bytes: 64 * 1024,
|
|
361
|
+
max_stderr_bytes: 64 * 1024
|
|
362
|
+
)
|
|
363
|
+
version = [stdout, stderr].map(&:strip).find { |text| !text.empty? }
|
|
364
|
+
version = version.to_s.lines.first.to_s.strip
|
|
365
|
+
status.success? && !version.empty? ? "#{path} (#{version})" : "#{path} (version unavailable)"
|
|
366
|
+
rescue Graphomaton::ProcessRunner::Error, SystemCallError
|
|
367
|
+
"#{path} (version unavailable)"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def emit_diagnostics(diagnostics, format:, stream:)
|
|
371
|
+
if format.to_sym == :json
|
|
372
|
+
payload = diagnostics.map do |diagnostic|
|
|
373
|
+
{
|
|
374
|
+
code: diagnostic.code,
|
|
375
|
+
severity: diagnostic.severity,
|
|
376
|
+
path: diagnostic.path,
|
|
377
|
+
message: diagnostic.message,
|
|
378
|
+
hint: diagnostic.hint
|
|
379
|
+
}.compact
|
|
380
|
+
end
|
|
381
|
+
stream.puts(JSON.generate(payload))
|
|
382
|
+
else
|
|
383
|
+
diagnostics.each do |diagnostic|
|
|
384
|
+
location = diagnostic.path.empty? ? '' : " at #{diagnostic.path.join('.')}"
|
|
385
|
+
stream.puts("#{diagnostic.severity}: #{diagnostic.code}#{location}: #{diagnostic.message}")
|
|
386
|
+
stream.puts(" hint: #{diagnostic.hint}") if diagnostic.hint
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def execute(arguments)
|
|
392
|
+
command = extract_command(arguments)
|
|
393
|
+
return execute_list(arguments) if command == :list
|
|
394
|
+
return execute_doctor(arguments) if command == :doctor
|
|
395
|
+
return execute_completion(arguments) if command == :completion
|
|
396
|
+
return execute_man(arguments) if command == :man
|
|
397
|
+
return execute_themes(arguments) if command == :themes
|
|
398
|
+
if arguments.include?('--version')
|
|
399
|
+
puts Graphomaton::VERSION
|
|
400
|
+
halt(EXIT_SUCCESS)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
selected_config_path, config_required = config_path(arguments)
|
|
404
|
+
environment = environment_options
|
|
405
|
+
argument_format = format_hint(arguments)
|
|
406
|
+
selected_format = argument_format || environment[:format]
|
|
407
|
+
configured_options = Config.load(
|
|
408
|
+
selected_config_path,
|
|
409
|
+
format: selected_format,
|
|
410
|
+
required: config_required
|
|
411
|
+
)
|
|
412
|
+
if selected_format.nil?
|
|
413
|
+
configured_format = configured_options[:format]
|
|
414
|
+
configured_output = configured_options[:output]
|
|
415
|
+
configured_format ||= File.extname(configured_output) if configured_output && configured_output != '-'
|
|
416
|
+
if configured_format && !configured_format.to_s.empty?
|
|
417
|
+
selected_format = Graphomaton::EXPORTERS.resolve(configured_format)
|
|
418
|
+
configured_options = Config.load(selected_config_path, format: selected_format, required: config_required)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
options = {
|
|
422
|
+
width: 800,
|
|
423
|
+
height: 600,
|
|
424
|
+
max_input_bytes: Graphomaton::DEFAULT_MAX_INPUT_BYTES,
|
|
425
|
+
max_states: Graphomaton::DEFAULT_MAX_STATES,
|
|
426
|
+
max_transitions: Graphomaton::DEFAULT_MAX_TRANSITIONS,
|
|
427
|
+
max_metadata_depth: Graphomaton::DEFAULT_MAX_METADATA_DEPTH,
|
|
428
|
+
max_label_length: Graphomaton::DEFAULT_MAX_LABEL_LENGTH,
|
|
429
|
+
max_group_depth: Graphomaton::DEFAULT_MAX_GROUP_DEPTH,
|
|
430
|
+
validate: true
|
|
431
|
+
}.merge(configured_options).merge(environment)
|
|
432
|
+
options[:format] = argument_format if argument_format
|
|
433
|
+
|
|
434
|
+
parser = OptionParser.new do |opts|
|
|
435
|
+
opts.banner = 'Usage: graphomaton [render] --input automaton.yml --output diagram.svg [options]'
|
|
436
|
+
opts.separator ' graphomaton validate automaton.yml [--diagnostics text|json]'
|
|
437
|
+
opts.separator ' graphomaton list formats|layouts|themes|converters'
|
|
438
|
+
opts.separator ' graphomaton themes | doctor'
|
|
439
|
+
opts.separator ' graphomaton --theme-gallery --output theme_gallery.html [options]'
|
|
440
|
+
|
|
441
|
+
opts.on('--config PATH', 'Configuration file (default: .graphomaton.yml)') { |value| options[:config] = value }
|
|
442
|
+
opts.on('-i', '--input PATH', 'Input JSON or YAML file') { |value| options[:input] = value }
|
|
443
|
+
opts.on('--input-format FORMAT', 'Input format for stdin or extension override') { |value| options[:input_format] = value.to_sym }
|
|
444
|
+
opts.on('-o', '--output PATH', 'Output file path') { |value| options[:output] = value }
|
|
445
|
+
opts.on('--no-clobber', 'Fail if the output file already exists') { options[:no_clobber] = true }
|
|
446
|
+
opts.on('--force', 'Allow replacing an existing output file') { options[:no_clobber] = false }
|
|
447
|
+
opts.on('-f', '--format FORMAT', 'Output format override') { |value| options[:format] = value.to_sym }
|
|
448
|
+
opts.on('--[no-]validate', 'Validate automaton references before rendering (default: enabled)') { |value| options[:validate] = value }
|
|
449
|
+
opts.on('--diagnostics FORMAT', 'Diagnostic output: text or json') { |value| options[:diagnostics] = value.to_sym }
|
|
450
|
+
opts.on('--fail-on-warning', 'Return a failure status when warnings are emitted') { options[:fail_on_warning] = true }
|
|
451
|
+
opts.on('--strict-semantics', 'Reject information loss in the selected output format') { options[:strict_semantics] = true }
|
|
452
|
+
opts.on('--debug', 'Include exception details in errors') { options[:debug] = true }
|
|
453
|
+
opts.on('--layout-warnings', 'Print SVG layout clipping warnings before rendering') { options[:layout_warnings] = true }
|
|
454
|
+
opts.on('--width WIDTH', Integer, 'Output width for size-aware formats') { |value| options[:width] = value }
|
|
455
|
+
opts.on('--height HEIGHT', Integer, 'Output height for size-aware formats') { |value| options[:height] = value }
|
|
456
|
+
opts.on('--scale SCALE', Float, 'PNG output scale') { |value| options[:scale] = value }
|
|
457
|
+
opts.on('--converter CONVERTER', 'SVG conversion backend for PNG, PDF, or WebP') { |value| options[:converter] = value.to_sym }
|
|
458
|
+
opts.on('--timeout SECONDS', Float, 'Converter timeout in seconds') { |value| options[:timeout] = value }
|
|
459
|
+
opts.on('--max-output-bytes BYTES', Integer, 'Maximum converter output size') { |value| options[:max_output_bytes] = value }
|
|
460
|
+
opts.on('--max-input-bytes BYTES', Integer, 'Maximum JSON or YAML input size') { |value| options[:max_input_bytes] = value }
|
|
461
|
+
opts.on('--max-states COUNT', Integer, 'Maximum parsed state count') { |value| options[:max_states] = value }
|
|
462
|
+
opts.on('--max-transitions COUNT', Integer, 'Maximum parsed transition count') { |value| options[:max_transitions] = value }
|
|
463
|
+
opts.on('--max-metadata-depth DEPTH', Integer, 'Maximum nested metadata depth') { |value| options[:max_metadata_depth] = value }
|
|
464
|
+
opts.on('--max-label-length BYTES', Integer, 'Maximum label size in bytes') { |value| options[:max_label_length] = value }
|
|
465
|
+
opts.on('--max-group-depth DEPTH', Integer, 'Maximum state hierarchy depth') { |value| options[:max_group_depth] = value }
|
|
466
|
+
opts.on('--theme THEME', 'Theme name') { |value| options[:theme] = value.to_sym }
|
|
467
|
+
opts.on('--theme-file PATH', 'Theme JSON or YAML file') { |value| options[:theme_file] = value }
|
|
468
|
+
opts.on('--theme-gallery', 'Write a standalone HTML gallery of built-in themes') { options[:theme_gallery] = true }
|
|
469
|
+
opts.on('--theme-gallery-animated', 'Animate the standalone theme gallery preview') { options[:theme_gallery_animated] = true }
|
|
470
|
+
opts.on('--list-themes', 'Print built-in theme names') { options[:list_themes] = true }
|
|
471
|
+
opts.on('--layout LAYOUT', 'SVG layout') { |value| options[:layout] = value.to_sym }
|
|
472
|
+
opts.on('--direction DIRECTION', 'Layout direction') { |value| options[:direction] = value.to_sym }
|
|
473
|
+
opts.on('--fit FIT', 'Fit mode for resolved SVG positions') { |value| options[:fit] = value.to_sym }
|
|
474
|
+
opts.on('--padding PADDING', Float, 'SVG layout padding') { |value| options[:padding] = value }
|
|
475
|
+
opts.on('--node-spacing SPACING', Float, 'SVG node spacing') { |value| options[:node_spacing] = value }
|
|
476
|
+
opts.on('--rank-spacing SPACING', Float, 'SVG rank spacing for layered layouts') { |value| options[:rank_spacing] = value }
|
|
477
|
+
opts.on('--force-iterations COUNT', Integer, 'SVG force layout iteration count') { |value| options[:force_iterations] = value }
|
|
478
|
+
opts.on('--layout-seed SEED', Integer, 'SVG force layout random seed') { |value| options[:layout_seed] = value }
|
|
479
|
+
opts.on('--graphviz-command COMMAND', 'Graphviz dot command for graphviz SVG layout') { |value| options[:graphviz_command] = value }
|
|
480
|
+
opts.on('--auto-density-spacing', 'Increase SVG spacing for dense graphs') { options[:auto_density_spacing] = true }
|
|
481
|
+
opts.on('--initial-position POSITION', 'Initial state placement mode') { |value| options[:initial_position] = value.to_sym }
|
|
482
|
+
opts.on('--final-position POSITION', 'Final state placement mode') { |value| options[:final_position] = value.to_sym }
|
|
483
|
+
opts.on('--responsive', 'Render responsive SVG width and height attributes') { options[:responsive] = true }
|
|
484
|
+
opts.on('--state-radius RADIUS', Float, 'SVG state radius') { |value| options[:state_radius] = value }
|
|
485
|
+
opts.on('--auto-state-radius', 'Grow SVG state radius from state label width') { options[:auto_state_radius] = true }
|
|
486
|
+
opts.on('--min-state-radius RADIUS', Float, 'Minimum SVG auto state radius') { |value| options[:min_state_radius] = value }
|
|
487
|
+
opts.on('--max-state-radius RADIUS', Float, 'Maximum SVG auto state radius') { |value| options[:max_state_radius] = value }
|
|
488
|
+
opts.on('--state-stroke-width WIDTH', Float, 'SVG state stroke width') { |value| options[:state_stroke_width] = value }
|
|
489
|
+
opts.on('--transition-stroke-width WIDTH', Float, 'SVG transition stroke width') { |value| options[:transition_stroke_width] = value }
|
|
490
|
+
opts.on('--state-shape SHAPE', 'SVG state shape') { |value| options[:state_shape] = value.to_sym }
|
|
491
|
+
opts.on('--edge-style STYLE', 'SVG edge style') { |value| options[:edge_style] = value.to_sym }
|
|
492
|
+
opts.on('--arrow-shape SHAPE', 'SVG arrowhead shape') { |value| options[:arrow_shape] = value.to_sym }
|
|
493
|
+
opts.on('--arrow-size SIZE', Float, 'SVG arrowhead size') { |value| options[:arrow_size] = value }
|
|
494
|
+
opts.on('--state-effect EFFECT', 'SVG state effect') { |value| options[:state_effect] = value.to_sym }
|
|
495
|
+
opts.on('--font-family FAMILY', 'SVG font family') { |value| options[:font_family] = value }
|
|
496
|
+
opts.on('--state-font-weight WEIGHT', 'SVG state font weight') { |value| options[:state_font_weight] = value }
|
|
497
|
+
opts.on('--transition-font-weight WEIGHT', 'SVG transition font weight') { |value| options[:transition_font_weight] = value }
|
|
498
|
+
opts.on('--no-preserve-manual-positions', 'Allow automatic layouts to reposition states with explicit coordinates') { options[:preserve_manual_positions] = false }
|
|
499
|
+
opts.on('--auto-size', 'Expand SVG canvas from resolved graph bounds') { options[:auto_size] = true }
|
|
500
|
+
opts.on('--xml-declaration', 'Include an XML declaration in SVG output') { options[:xml_declaration] = true }
|
|
501
|
+
opts.on('--pretty', 'Pretty-print SVG output') { options[:pretty] = true }
|
|
502
|
+
opts.on('--minify', 'Minify SVG output') { options[:minify] = true }
|
|
503
|
+
opts.on('--css-variables', 'Emit SVG theme values as CSS variables') { options[:css_variables] = true }
|
|
504
|
+
opts.on('--no-embed-styles', 'Skip embedded SVG style block') { options[:embed_styles] = false }
|
|
505
|
+
opts.on('--svg-id ID', 'Stable SVG root ID prefix') { |value| options[:svg_id] = value }
|
|
506
|
+
opts.on('--wrap-labels', 'Wrap long SVG transition labels') { options[:wrap] = true }
|
|
507
|
+
opts.on('--max-transition-label-width WIDTH', Float, 'Maximum SVG transition label width before wrapping') { |value| options[:max_transition_label_width] = value }
|
|
508
|
+
opts.on('--state-wrap', 'Wrap long SVG state labels') { options[:state_wrap] = true }
|
|
509
|
+
opts.on('--max-state-label-width WIDTH', Float, 'Maximum SVG state label width before wrapping') { |value| options[:max_state_label_width] = value }
|
|
510
|
+
opts.on('--label-tooltips', 'Add SVG title tooltips for labels') { options[:label_tooltips] = true }
|
|
511
|
+
opts.on('--html-tooltips', 'Add SVG data-tooltip attributes for HTML wrappers') { options[:html_tooltips] = true }
|
|
512
|
+
opts.on('--sort-labels', 'Sort merged SVG transition labels') { options[:sort_labels] = true }
|
|
513
|
+
opts.on('--rotate-labels', 'Rotate SVG transition labels along edges') { options[:rotate_labels] = true }
|
|
514
|
+
opts.on('--label-padding PADDING', Float, 'SVG transition label padding') { |value| options[:label_padding] = value }
|
|
515
|
+
opts.on('--label-radius RADIUS', Float, 'SVG transition label corner radius') { |value| options[:label_radius] = value }
|
|
516
|
+
opts.on('--label-border', 'Draw SVG transition label borders') { options[:label_border] = true }
|
|
517
|
+
opts.on('--no-label-background', 'Hide SVG transition label backgrounds') { options[:label_background] = false }
|
|
518
|
+
opts.on('--initial-arrow-length LENGTH', Float, 'SVG initial arrow length') { |value| options[:initial_arrow_length] = value }
|
|
519
|
+
opts.on('--initial-arrow-label LABEL', 'SVG initial arrow label') { |value| options[:initial_arrow_label] = value }
|
|
520
|
+
opts.on('--no-initial-arrow-label', 'Hide the SVG initial arrow label') { options[:initial_arrow_label] = nil }
|
|
521
|
+
opts.on('--final-arrow-length LENGTH', Float, 'SVG final arrow length') { |value| options[:final_arrow_length] = value }
|
|
522
|
+
opts.on('--final-arrow-label LABEL', 'SVG final arrow label') { |value| options[:final_arrow_label] = value }
|
|
523
|
+
opts.on('--no-final-arrow-label', 'Hide SVG final arrow labels') { options[:final_arrow_label] = nil }
|
|
524
|
+
opts.on('--show-final-arrows', 'Render native SVG arrows from final states') { options[:show_final_arrows] = true }
|
|
525
|
+
opts.on('--scc-groups', 'Render SVG groups around strongly connected components') { options[:scc_groups] = true }
|
|
526
|
+
opts.on('--fold-groups', 'Fold grouped SVG states into compound nodes') { options[:fold_groups] = true }
|
|
527
|
+
opts.on('--highlight-unreachable', 'Highlight unreachable states in SVG output') { options[:highlight_unreachable] = true }
|
|
528
|
+
opts.on('--unreachable-zone POSITION', 'Move unreachable SVG states to none, right, bottom, left, or top') { |value| options[:unreachable_zone] = value.to_sym }
|
|
529
|
+
opts.on('--highlight-dead-states', 'Highlight dead and trap states in SVG output') { options[:highlight_dead_states] = true }
|
|
530
|
+
opts.on('--highlight-initial-state', 'Highlight the initial state in SVG output') { options[:highlight_initial_state] = true }
|
|
531
|
+
opts.on('--highlight-final-states', 'Highlight accepting states in SVG output') { options[:highlight_final_states] = true }
|
|
532
|
+
opts.on('--highlight-transition SPEC', 'Highlight SVG transition FROM:TO[:LABEL]') do |value|
|
|
533
|
+
parts = value.split(':', 3)
|
|
534
|
+
raise OptionParser::InvalidArgument, value if parts.size < 2
|
|
535
|
+
|
|
536
|
+
target = { from: parts[0], to: parts[1] }
|
|
537
|
+
target[:label] = parts[2] if parts[2]
|
|
538
|
+
options[:highlight_transitions] ||= []
|
|
539
|
+
options[:highlight_transitions] << target
|
|
540
|
+
end
|
|
541
|
+
opts.on('--loop-position POSITION', 'SVG self-loop placement') { |value| options[:loop_position] = value.to_sym }
|
|
542
|
+
opts.on('--cdn URL_OR_PATH', 'Mermaid CDN URL or local script path for HTML output') { |value| options[:cdn] = value }
|
|
543
|
+
opts.on('--offline', 'Use a non-module Mermaid script tag for HTML output') { options[:offline] = true }
|
|
544
|
+
opts.on('--inline-mermaid', 'Inline Mermaid script from --cdn path in HTML output') { options[:inline_mermaid] = true }
|
|
545
|
+
opts.on('--inline-mathjax', 'Inline MathJax script from --mathjax-cdn path') { options[:inline_mathjax] = true }
|
|
546
|
+
opts.on('--self-contained', 'Inline all configured local HTML assets') { options[:self_contained] = true }
|
|
547
|
+
opts.on('--nonce NONCE', 'Add a CSP nonce to generated HTML scripts and styles') { |value| options[:nonce] = value }
|
|
548
|
+
opts.on('--csp', 'Add a strict Content Security Policy meta tag (requires --nonce)') { options[:csp] = true }
|
|
549
|
+
opts.on('--csp-policy POLICY', 'Add a custom Content Security Policy meta tag') { |value| options[:csp] = value }
|
|
550
|
+
opts.on('--mermaid-sha256 HEX', 'Verify an inlined Mermaid asset') { |value| options[:mermaid_sha256] = value }
|
|
551
|
+
opts.on('--mathjax-sha256 HEX', 'Verify an inlined MathJax asset') { |value| options[:mathjax_sha256] = value }
|
|
552
|
+
opts.on('--title TITLE', 'HTML page or accessible SVG title') { |value| options[:title] = value }
|
|
553
|
+
opts.on('--description TEXT', 'Accessible SVG description') { |value| options[:description] = value }
|
|
554
|
+
opts.on('--lang LANG', 'HTML language code') { |value| options[:lang] = value }
|
|
555
|
+
opts.on('--show-source', 'Include Mermaid source in HTML output') { options[:show_source] = true }
|
|
556
|
+
opts.on('--pan-zoom', 'Add pan and zoom controls to HTML output') { options[:pan_zoom] = true }
|
|
557
|
+
opts.on('--mathjax', 'Include MathJax support in HTML output') { options[:mathjax] = true }
|
|
558
|
+
opts.on('--mathjax-cdn URL_OR_PATH', 'MathJax script URL or local path for HTML output') { |value| options[:mathjax_cdn] = value }
|
|
559
|
+
opts.on('--notes', 'Include state metadata notes in Mermaid output') { options[:notes] = true }
|
|
560
|
+
opts.on('--class-defs', 'Include Mermaid class definitions in Mermaid output') { options[:class_defs] = true }
|
|
561
|
+
opts.on('--rank-constraints', 'Emit DOT rank constraints for initial and final states') { options[:rank_constraints] = true }
|
|
562
|
+
opts.on('--[no-]merge-parallel-transitions', 'Merge equivalent parallel SVG transitions') { |value| options[:merge_parallel_transitions] = value }
|
|
563
|
+
opts.on('--version', 'Print version') { options[:version] = true }
|
|
564
|
+
opts.on('-h', '--help', 'Print help') do
|
|
565
|
+
puts opts
|
|
566
|
+
halt(EXIT_SUCCESS)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
begin
|
|
571
|
+
parser.parse!(arguments)
|
|
572
|
+
validate_cli_numeric_options!(options)
|
|
573
|
+
rescue OptionParser::ParseError => e
|
|
574
|
+
report_exception(e)
|
|
575
|
+
halt(EXIT_USAGE)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
input_path = options[:input] || arguments.shift
|
|
579
|
+
output_path = options[:output]
|
|
580
|
+
output_path ||= arguments.shift if command == :render
|
|
581
|
+
|
|
582
|
+
unless arguments.empty?
|
|
583
|
+
warn "Unexpected arguments: #{arguments.join(' ')}"
|
|
584
|
+
halt(EXIT_USAGE)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
if options[:version]
|
|
588
|
+
puts Graphomaton::VERSION
|
|
589
|
+
halt(EXIT_SUCCESS)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
unless %i[text json].include?((options[:diagnostics] || :text).to_sym)
|
|
593
|
+
warn '--diagnostics must be text or json'
|
|
594
|
+
halt(EXIT_USAGE)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
if options[:list_themes]
|
|
598
|
+
puts Graphomaton::Theme.available_names.join("\n")
|
|
599
|
+
halt(EXIT_SUCCESS)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
if options[:theme_gallery]
|
|
603
|
+
if output_path.nil?
|
|
604
|
+
warn parser
|
|
605
|
+
halt(EXIT_USAGE)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
if options[:no_clobber] && File.exist?(output_path)
|
|
609
|
+
warn "Output file already exists: #{output_path}"
|
|
610
|
+
halt(EXIT_EXPORT)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
themes = Graphomaton::Exporters::Svg::THEMES.dup
|
|
614
|
+
themes = themes.merge(custom: load_theme_file(options[:theme_file])) if options[:theme_file]
|
|
615
|
+
Graphomaton::Theme.save_gallery_html(
|
|
616
|
+
output_path,
|
|
617
|
+
title: options[:title] || 'Graphomaton Theme Gallery',
|
|
618
|
+
themes: themes,
|
|
619
|
+
animated: options[:theme_gallery_animated]
|
|
620
|
+
)
|
|
621
|
+
halt(EXIT_SUCCESS)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
if input_path.nil? || (command == :render && output_path.nil?)
|
|
625
|
+
warn parser
|
|
626
|
+
halt(EXIT_USAGE)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
if command == :render && options[:no_clobber] && output_path != '-' && File.exist?(output_path)
|
|
630
|
+
warn "Output file already exists: #{output_path}"
|
|
631
|
+
halt(EXIT_EXPORT)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
begin
|
|
635
|
+
automaton = load_automaton(
|
|
636
|
+
input_path,
|
|
637
|
+
input_format: options[:input_format],
|
|
638
|
+
limits: {
|
|
639
|
+
max_input_bytes: options[:max_input_bytes],
|
|
640
|
+
max_states: options[:max_states],
|
|
641
|
+
max_transitions: options[:max_transitions],
|
|
642
|
+
max_metadata_depth: options[:max_metadata_depth],
|
|
643
|
+
max_label_length: options[:max_label_length],
|
|
644
|
+
max_group_depth: options[:max_group_depth]
|
|
645
|
+
}
|
|
646
|
+
)
|
|
647
|
+
rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
|
|
648
|
+
report_exception(e, prefix: 'Input error')
|
|
649
|
+
halt(EXIT_INPUT)
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
if command == :validate
|
|
653
|
+
diagnostics = automaton.validation_diagnostics(profile: :all)
|
|
654
|
+
emit_diagnostics(diagnostics, format: options[:diagnostics] || :text, stream: @stdout)
|
|
655
|
+
has_errors = diagnostics.any? { |diagnostic| diagnostic.severity == :error }
|
|
656
|
+
has_warnings = diagnostics.any? { |diagnostic| diagnostic.severity == :warning }
|
|
657
|
+
halt(EXIT_VALIDATION) if has_errors || (options[:fail_on_warning] && has_warnings)
|
|
658
|
+
halt(EXIT_SUCCESS)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
if options[:validate]
|
|
662
|
+
begin
|
|
663
|
+
automaton.validate!
|
|
664
|
+
rescue Graphomaton::ValidationError => e
|
|
665
|
+
report_exception(e)
|
|
666
|
+
halt(EXIT_VALIDATION)
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
if options[:theme_file]
|
|
671
|
+
options[:theme] = load_theme_file(options[:theme_file])
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
format_name = options[:format] || File.extname(output_path).delete_prefix('.')
|
|
675
|
+
if output_path == '-' && options[:format].nil?
|
|
676
|
+
warn '--format is required when writing to standard output'
|
|
677
|
+
halt(EXIT_USAGE)
|
|
678
|
+
end
|
|
679
|
+
begin
|
|
680
|
+
resolved_output_format = Graphomaton::EXPORTERS.resolve(format_name)
|
|
681
|
+
rescue ArgumentError => e
|
|
682
|
+
report_exception(e)
|
|
683
|
+
halt(EXIT_USAGE)
|
|
684
|
+
end
|
|
685
|
+
begin
|
|
686
|
+
validate_format_options!(options, resolved_output_format)
|
|
687
|
+
rescue OptionParser::ParseError => e
|
|
688
|
+
report_exception(e)
|
|
689
|
+
halt(EXIT_USAGE)
|
|
690
|
+
end
|
|
691
|
+
svg_backed_format = %i[svg png pdf webp].include?(resolved_output_format)
|
|
692
|
+
|
|
693
|
+
save_options = {}
|
|
694
|
+
if options[:theme]
|
|
695
|
+
save_options[:theme] = options[:theme] if svg_backed_format ||
|
|
696
|
+
%i[dot plantuml].include?(resolved_output_format) ||
|
|
697
|
+
(resolved_output_format == :html && !options[:theme].is_a?(Hash))
|
|
698
|
+
end
|
|
699
|
+
save_options[:direction] = options[:direction] if options[:direction] && (svg_backed_format || %i[html mermaid dot plantuml].include?(resolved_output_format))
|
|
700
|
+
if svg_backed_format
|
|
701
|
+
save_options[:layout] = options[:layout] if options[:layout]
|
|
702
|
+
save_options[:title] = options[:title] if options[:title]
|
|
703
|
+
save_options[:description] = options[:description] if options[:description]
|
|
704
|
+
save_options[:merge_parallel_transitions] = options[:merge_parallel_transitions] if options.key?(:merge_parallel_transitions)
|
|
705
|
+
save_options[:fit] = options[:fit] if options[:fit]
|
|
706
|
+
save_options[:padding] = options[:padding] if options[:padding]
|
|
707
|
+
save_options[:node_spacing] = options[:node_spacing] if options[:node_spacing]
|
|
708
|
+
save_options[:rank_spacing] = options[:rank_spacing] if options[:rank_spacing]
|
|
709
|
+
save_options[:force_iterations] = options[:force_iterations] if options[:force_iterations]
|
|
710
|
+
save_options[:layout_seed] = options[:layout_seed] if options[:layout_seed]
|
|
711
|
+
save_options[:graphviz_command] = options[:graphviz_command] if options[:graphviz_command]
|
|
712
|
+
save_options[:auto_density_spacing] = options[:auto_density_spacing] if options.key?(:auto_density_spacing)
|
|
713
|
+
save_options[:initial_position] = options[:initial_position] if options[:initial_position]
|
|
714
|
+
save_options[:final_position] = options[:final_position] if options[:final_position]
|
|
715
|
+
save_options[:responsive] = options[:responsive] if options.key?(:responsive)
|
|
716
|
+
save_options[:state_radius] = options[:state_radius] if options[:state_radius]
|
|
717
|
+
save_options[:auto_state_radius] = options[:auto_state_radius] if options.key?(:auto_state_radius)
|
|
718
|
+
save_options[:min_state_radius] = options[:min_state_radius] if options[:min_state_radius]
|
|
719
|
+
save_options[:max_state_radius] = options[:max_state_radius] if options[:max_state_radius]
|
|
720
|
+
save_options[:state_stroke_width] = options[:state_stroke_width] if options[:state_stroke_width]
|
|
721
|
+
save_options[:transition_stroke_width] = options[:transition_stroke_width] if options[:transition_stroke_width]
|
|
722
|
+
save_options[:state_shape] = options[:state_shape] if options[:state_shape]
|
|
723
|
+
save_options[:edge_style] = options[:edge_style] if options[:edge_style]
|
|
724
|
+
save_options[:arrow_shape] = options[:arrow_shape] if options[:arrow_shape]
|
|
725
|
+
save_options[:arrow_size] = options[:arrow_size] if options[:arrow_size]
|
|
726
|
+
save_options[:state_effect] = options[:state_effect] if options[:state_effect]
|
|
727
|
+
save_options[:font_family] = options[:font_family] if options[:font_family]
|
|
728
|
+
save_options[:state_font_weight] = options[:state_font_weight] if options[:state_font_weight]
|
|
729
|
+
save_options[:transition_font_weight] = options[:transition_font_weight] if options[:transition_font_weight]
|
|
730
|
+
save_options[:preserve_manual_positions] = options[:preserve_manual_positions] if options.key?(:preserve_manual_positions)
|
|
731
|
+
save_options[:auto_size] = options[:auto_size] if options.key?(:auto_size)
|
|
732
|
+
save_options[:xml_declaration] = options[:xml_declaration] if options.key?(:xml_declaration)
|
|
733
|
+
save_options[:pretty] = options[:pretty] if options.key?(:pretty)
|
|
734
|
+
save_options[:minify] = options[:minify] if options.key?(:minify)
|
|
735
|
+
save_options[:css_variables] = options[:css_variables] if options.key?(:css_variables)
|
|
736
|
+
save_options[:embed_styles] = options[:embed_styles] if options.key?(:embed_styles)
|
|
737
|
+
save_options[:svg_id] = options[:svg_id] if options[:svg_id]
|
|
738
|
+
save_options[:wrap] = options[:wrap] if options.key?(:wrap)
|
|
739
|
+
save_options[:max_transition_label_width] = options[:max_transition_label_width] if options[:max_transition_label_width]
|
|
740
|
+
save_options[:state_wrap] = options[:state_wrap] if options.key?(:state_wrap)
|
|
741
|
+
save_options[:max_state_label_width] = options[:max_state_label_width] if options[:max_state_label_width]
|
|
742
|
+
save_options[:label_tooltips] = options[:label_tooltips] if options.key?(:label_tooltips)
|
|
743
|
+
save_options[:html_tooltips] = options[:html_tooltips] if options.key?(:html_tooltips)
|
|
744
|
+
save_options[:sort_labels] = options[:sort_labels] if options.key?(:sort_labels)
|
|
745
|
+
save_options[:rotate_labels] = options[:rotate_labels] if options.key?(:rotate_labels)
|
|
746
|
+
save_options[:label_padding] = options[:label_padding] if options[:label_padding]
|
|
747
|
+
save_options[:label_radius] = options[:label_radius] if options[:label_radius]
|
|
748
|
+
save_options[:label_border] = options[:label_border] if options.key?(:label_border)
|
|
749
|
+
save_options[:label_background] = options[:label_background] if options.key?(:label_background)
|
|
750
|
+
save_options[:initial_arrow_length] = options[:initial_arrow_length] if options[:initial_arrow_length]
|
|
751
|
+
save_options[:initial_arrow_label] = options[:initial_arrow_label] if options.key?(:initial_arrow_label)
|
|
752
|
+
save_options[:final_arrow_length] = options[:final_arrow_length] if options[:final_arrow_length]
|
|
753
|
+
save_options[:final_arrow_label] = options[:final_arrow_label] if options.key?(:final_arrow_label)
|
|
754
|
+
save_options[:show_final_arrows] = options[:show_final_arrows] if options.key?(:show_final_arrows)
|
|
755
|
+
save_options[:scc_groups] = options[:scc_groups] if options.key?(:scc_groups)
|
|
756
|
+
save_options[:fold_groups] = options[:fold_groups] if options.key?(:fold_groups)
|
|
757
|
+
save_options[:highlight_unreachable] = options[:highlight_unreachable] if options.key?(:highlight_unreachable)
|
|
758
|
+
save_options[:unreachable_zone] = options[:unreachable_zone] if options[:unreachable_zone]
|
|
759
|
+
save_options[:highlight_dead_states] = options[:highlight_dead_states] if options.key?(:highlight_dead_states)
|
|
760
|
+
save_options[:highlight_initial_state] = options[:highlight_initial_state] if options.key?(:highlight_initial_state)
|
|
761
|
+
save_options[:highlight_final_states] = options[:highlight_final_states] if options.key?(:highlight_final_states)
|
|
762
|
+
save_options[:highlight_transitions] = options[:highlight_transitions] if options[:highlight_transitions]
|
|
763
|
+
save_options[:loop_position] = options[:loop_position] if options[:loop_position]
|
|
764
|
+
end
|
|
765
|
+
if %i[png pdf webp].include?(resolved_output_format)
|
|
766
|
+
save_options[:converter] = options[:converter] if options[:converter]
|
|
767
|
+
save_options[:timeout] = options[:timeout] if options[:timeout]
|
|
768
|
+
save_options[:max_output_bytes] = options[:max_output_bytes] if options[:max_output_bytes]
|
|
769
|
+
end
|
|
770
|
+
save_options[:scale] = options[:scale] if options[:scale] && resolved_output_format == :png
|
|
771
|
+
if resolved_output_format == :html
|
|
772
|
+
save_options[:cdn] = options[:cdn] if options[:cdn]
|
|
773
|
+
save_options[:offline] = options[:offline] if options.key?(:offline)
|
|
774
|
+
save_options[:inline_mermaid] = options[:inline_mermaid] if options.key?(:inline_mermaid)
|
|
775
|
+
save_options[:inline_mathjax] = options[:inline_mathjax] if options.key?(:inline_mathjax)
|
|
776
|
+
save_options[:self_contained] = options[:self_contained] if options.key?(:self_contained)
|
|
777
|
+
save_options[:nonce] = options[:nonce] if options[:nonce]
|
|
778
|
+
save_options[:csp] = options[:csp] if options.key?(:csp)
|
|
779
|
+
save_options[:mermaid_sha256] = options[:mermaid_sha256] if options[:mermaid_sha256]
|
|
780
|
+
save_options[:mathjax_sha256] = options[:mathjax_sha256] if options[:mathjax_sha256]
|
|
781
|
+
save_options[:title] = options[:title] if options[:title]
|
|
782
|
+
save_options[:lang] = options[:lang] if options[:lang]
|
|
783
|
+
save_options[:show_source] = options[:show_source] if options.key?(:show_source)
|
|
784
|
+
save_options[:pan_zoom] = options[:pan_zoom] if options.key?(:pan_zoom)
|
|
785
|
+
save_options[:mathjax] = options[:mathjax] if options.key?(:mathjax)
|
|
786
|
+
save_options[:mathjax_cdn] = options[:mathjax_cdn] if options[:mathjax_cdn]
|
|
787
|
+
end
|
|
788
|
+
if %i[html mermaid plantuml].include?(resolved_output_format)
|
|
789
|
+
save_options[:notes] = options[:notes] if options.key?(:notes)
|
|
790
|
+
end
|
|
791
|
+
if %i[html mermaid].include?(resolved_output_format)
|
|
792
|
+
save_options[:class_defs] = options[:class_defs] if options.key?(:class_defs)
|
|
793
|
+
end
|
|
794
|
+
save_options[:rank_constraints] = options[:rank_constraints] if options.key?(:rank_constraints) && resolved_output_format == :dot
|
|
795
|
+
|
|
796
|
+
begin
|
|
797
|
+
result = automaton.render_result(
|
|
798
|
+
format: resolved_output_format,
|
|
799
|
+
width: options[:width],
|
|
800
|
+
height: options[:height],
|
|
801
|
+
strict_semantics: options[:strict_semantics] || false,
|
|
802
|
+
**save_options
|
|
803
|
+
)
|
|
804
|
+
diagnostics = result.diagnostics.dup
|
|
805
|
+
diagnostics.concat(automaton.validation_diagnostics(profile: :fsm_semantics)) if options[:fail_on_warning]
|
|
806
|
+
visible_diagnostics = diagnostics.select do |diagnostic|
|
|
807
|
+
!diagnostic.code.start_with?('state-clipped-', 'label-') || options[:layout_warnings]
|
|
808
|
+
end
|
|
809
|
+
emit_diagnostics(visible_diagnostics, format: options[:diagnostics] || :text, stream: @stderr) if visible_diagnostics.any?
|
|
810
|
+
if options[:fail_on_warning] && diagnostics.any? { |diagnostic| diagnostic.severity == :warning }
|
|
811
|
+
halt(EXIT_VALIDATION)
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
if output_path == '-'
|
|
815
|
+
entry = Graphomaton::EXPORTERS.fetch(resolved_output_format)
|
|
816
|
+
@stdout.binmode if entry.binary && @stdout.respond_to?(:binmode)
|
|
817
|
+
@stdout.write(result.output)
|
|
818
|
+
else
|
|
819
|
+
Graphomaton::AtomicFile.write(
|
|
820
|
+
output_path,
|
|
821
|
+
result.output,
|
|
822
|
+
binary: Graphomaton::EXPORTERS.fetch(resolved_output_format).binary
|
|
823
|
+
)
|
|
824
|
+
end
|
|
825
|
+
rescue Graphomaton::SecurityError => e
|
|
826
|
+
report_exception(e)
|
|
827
|
+
halt(EXIT_SECURITY)
|
|
828
|
+
rescue Graphomaton::LayoutError => e
|
|
829
|
+
report_exception(e)
|
|
830
|
+
halt(EXIT_LAYOUT)
|
|
831
|
+
rescue Graphomaton::Exporters::Png::ConversionError,
|
|
832
|
+
Graphomaton::Exporters::Pdf::ConversionError,
|
|
833
|
+
Graphomaton::Exporters::Webp::ConversionError,
|
|
834
|
+
ArgumentError,
|
|
835
|
+
SystemCallError => e
|
|
836
|
+
report_exception(e)
|
|
837
|
+
halt(EXIT_EXPORT)
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
end
|