graphomaton 1.0.0 → 1.2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -8
  3. data/README.md +427 -44
  4. data/SECURITY.md +47 -0
  5. data/docs/architecture.md +30 -0
  6. data/docs/cli.md +28 -0
  7. data/docs/custom-exporters.md +39 -0
  8. data/docs/exporters.md +19 -0
  9. data/docs/input-schema.md +27 -0
  10. data/docs/migration-1.1.md +19 -0
  11. data/docs/performance.md +19 -0
  12. data/docs/releasing.md +19 -0
  13. data/exe/graphomaton +9 -0
  14. data/lib/graphomaton/atomic_file.rb +30 -0
  15. data/lib/graphomaton/cli/config.rb +102 -0
  16. data/lib/graphomaton/cli.rb +863 -0
  17. data/lib/graphomaton/errors.rb +11 -0
  18. data/lib/graphomaton/exporter_registry.rb +130 -0
  19. data/lib/graphomaton/exporters/dot.rb +255 -18
  20. data/lib/graphomaton/exporters/mermaid.rb +705 -25
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +250 -13
  23. data/lib/graphomaton/exporters/png.rb +176 -0
  24. data/lib/graphomaton/exporters/svg.rb +2808 -231
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +11 -4
  27. data/lib/graphomaton/identifier_allocator.rb +33 -0
  28. data/lib/graphomaton/input_policy.rb +83 -0
  29. data/lib/graphomaton/layout/force_tree.rb +127 -0
  30. data/lib/graphomaton/model.rb +230 -0
  31. data/lib/graphomaton/process_runner.rb +153 -0
  32. data/lib/graphomaton/url_policy.rb +40 -0
  33. data/lib/graphomaton/version.rb +1 -1
  34. data/lib/graphomaton.rb +2933 -54
  35. data/sig/graphomaton.rbs +129 -0
  36. metadata +38 -25
  37. data/.codespellignore +0 -0
  38. data/.rspec +0 -1
  39. data/CODE_OF_CONDUCT.md +0 -132
  40. data/Rakefile +0 -8
  41. data/sample/basic.rb +0 -30
  42. data/sample/complex.rb +0 -32
  43. data/sample/long_names.rb +0 -20
  44. data/sample/nfa.rb +0 -28
  45. data/sample/skip_states.rb +0 -23
  46. data/spec/exporters/dot_spec.rb +0 -146
  47. data/spec/exporters/mermaid_spec.rb +0 -154
  48. data/spec/exporters/plantuml_spec.rb +0 -144
  49. data/spec/exporters/svg_spec.rb +0 -314
  50. data/spec/graphomaton_edge_cases_spec.rb +0 -322
  51. data/spec/graphomaton_spec.rb +0 -371
  52. data/spec/spec_helper.rb +0 -13
data/lib/graphomaton.rb CHANGED
@@ -1,110 +1,2989 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'open3'
5
+ require 'shellwords'
6
+ require 'set'
7
+ require 'yaml'
8
+
9
+ require_relative 'graphomaton/atomic_file'
10
+ require_relative 'graphomaton/errors'
11
+ require_relative 'graphomaton/exporter_registry'
12
+ require_relative 'graphomaton/identifier_allocator'
13
+ require_relative 'graphomaton/input_policy'
14
+ require_relative 'graphomaton/layout/force_tree'
15
+ require_relative 'graphomaton/model'
16
+ require_relative 'graphomaton/process_runner'
17
+ require_relative 'graphomaton/url_policy'
3
18
  require_relative 'graphomaton/exporters'
4
19
  require_relative 'graphomaton/version'
5
20
 
6
21
  class Graphomaton
7
- attr_accessor :states, :transitions, :initial_state, :final_states
8
22
 
9
- def initialize
23
+ class Theme
24
+ def self.default
25
+ Exporters::Svg::THEMES.fetch(Exporters::Svg::DEFAULT_THEME)
26
+ end
27
+
28
+ def self.available_names
29
+ Exporters::Svg::THEMES.keys
30
+ end
31
+
32
+ def self.normalize(theme, context: 'Graphomaton theme')
33
+ raise ArgumentError, "#{context} must be a Hash" unless theme.is_a?(Hash)
34
+
35
+ normalized = theme.transform_keys { |key| key.to_sym }
36
+ unknown = normalized.keys - default.keys
37
+ raise ArgumentError, "Unknown #{context} keys: #{unknown.join(', ')}" unless unknown.empty?
38
+
39
+ normalized.each do |key, value|
40
+ validate_value(key, value, context: context)
41
+ end
42
+
43
+ default.merge(normalized)
44
+ end
45
+
46
+ def self.resolve(theme, context: 'Graphomaton theme', allow_auto: false)
47
+ return normalize(theme, context: context) if theme.is_a?(Hash)
48
+
49
+ theme_name = theme.to_s.to_sym
50
+ return default if allow_auto && theme_name == :auto
51
+
52
+ Exporters::Svg::THEMES.fetch(theme_name)
53
+ rescue KeyError
54
+ available = available_names
55
+ available = available + [:auto] if allow_auto
56
+ raise ArgumentError, "Unknown #{context}: #{theme.inspect}. Available themes: #{available.join(', ')}"
57
+ end
58
+
59
+ def self.gallery_html(title: 'Graphomaton Theme Gallery', themes: Exporters::Svg::THEMES, animated: false)
60
+ cards = themes.map do |name, theme|
61
+ normalized = normalize(theme)
62
+ theme_card(name, normalized)
63
+ end.join("\n")
64
+
65
+ <<~HTML
66
+ <!DOCTYPE html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="UTF-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
71
+ <title>#{escape_html(title)}</title>
72
+ <style>
73
+ body { background: #f8fafc; color: #0f172a; font-family: Georgia, serif; margin: 0; padding: 32px; }
74
+ h1 { font-size: clamp(2rem, 4vw, 4rem); margin: 0 0 24px; }
75
+ .theme-gallery { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
76
+ .theme-card { background: white; border: 1px solid #e2e8f0; border-radius: 18px; box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08); overflow: hidden; }
77
+ .theme-card h2 { font-size: 1rem; letter-spacing: 0.08em; margin: 0; padding: 16px 18px; text-transform: uppercase; }
78
+ .theme-card svg { display: block; width: 100%; }
79
+ #{theme_gallery_animation_css(animated)}
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <h1>#{escape_html(title)}</h1>
84
+ <div class="theme-gallery">
85
+ #{cards}
86
+ </div>
87
+ </body>
88
+ </html>
89
+ HTML
90
+ end
91
+
92
+ def self.save_gallery_html(filename, **options)
93
+ no_clobber = options.delete(:no_clobber) || false
94
+ AtomicFile.write(filename, gallery_html(**options), no_clobber: no_clobber)
95
+ end
96
+
97
+ def self.safe_css_value(value, context:)
98
+ string = value.to_s
99
+ if string.match?(/[\u0000-\u001f\u007f;{}\\]/) ||
100
+ string.match?(/\/\*|(?:url|src|image(?:-set)?|cross-fade|element|paint)\s*\(/i)
101
+ raise Graphomaton::SecurityError, "Unsafe #{context}: #{value.inspect}"
102
+ end
103
+
104
+ string
105
+ end
106
+
107
+ def self.theme_card(name, theme)
108
+ background = theme[:background] || '#ffffff'
109
+
110
+ <<~HTML
111
+ <article class="theme-card">
112
+ <h2>#{escape_html(name)}</h2>
113
+ <svg viewBox="0 0 260 150" role="img" aria-label="#{escape_html(name)} theme preview" style="background: #{escape_html(background)}">
114
+ <path d="M76 76 C112 32, 148 32, 184 76" fill="none" stroke="#{escape_html(theme[:stroke])}" stroke-width="3" marker-end="url(#arrow-#{escape_html(name)})"/>
115
+ <defs>
116
+ <marker id="arrow-#{escape_html(name)}" markerWidth="10" markerHeight="6" refX="9" refY="3" orient="auto">
117
+ <path d="M0 0 L10 3 L0 6 Z" fill="#{escape_html(theme[:stroke])}"/>
118
+ </marker>
119
+ </defs>
120
+ <circle cx="70" cy="82" r="28" fill="#{escape_html(theme[:state_fill])}" stroke="#{escape_html(theme[:stroke])}" stroke-width="3"/>
121
+ <circle cx="190" cy="82" r="28" fill="#{escape_html(theme[:state_fill])}" stroke="#{escape_html(theme[:stroke])}" stroke-width="3"/>
122
+ <text x="70" y="88" text-anchor="middle" fill="#{escape_html(theme[:state_text])}" font-size="18">A</text>
123
+ <text x="190" y="88" text-anchor="middle" fill="#{escape_html(theme[:state_text])}" font-size="18">B</text>
124
+ <rect x="113" y="42" width="34" height="22" rx="4" fill="#{escape_html(theme[:label_background])}" opacity="#{escape_html(theme[:label_opacity])}"/>
125
+ <text x="130" y="58" text-anchor="middle" fill="#{escape_html(theme[:transition_label])}" font-size="14">a</text>
126
+ </svg>
127
+ </article>
128
+ HTML
129
+ end
130
+ private_class_method :theme_card
131
+
132
+ def self.theme_gallery_animation_css(animated)
133
+ return '' unless animated
134
+
135
+ <<~CSS
136
+ .theme-card path { animation: graphomaton-gallery-dash 2.4s linear infinite; stroke-dasharray: 12 8; }
137
+ .theme-card circle { animation: graphomaton-gallery-pulse 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
138
+ @keyframes graphomaton-gallery-dash {
139
+ to { stroke-dashoffset: -40; }
140
+ }
141
+ @keyframes graphomaton-gallery-pulse {
142
+ 0%, 100% { transform: scale(1); }
143
+ 50% { transform: scale(1.05); }
144
+ }
145
+ @media (prefers-reduced-motion: reduce) {
146
+ .theme-card path,
147
+ .theme-card circle { animation: none; }
148
+ }
149
+ CSS
150
+ end
151
+ private_class_method :theme_gallery_animation_css
152
+
153
+ def self.escape_html(value)
154
+ value.to_s
155
+ .gsub('&', '&amp;')
156
+ .gsub('<', '&lt;')
157
+ .gsub('>', '&gt;')
158
+ .gsub('"', '&quot;')
159
+ .gsub("'", '&#39;')
160
+ end
161
+ private_class_method :escape_html
162
+
163
+ def self.validate_value(key, value, context:)
164
+ safe_css_value(value, context: "#{context} value for #{key}")
165
+
166
+ return unless key == :label_opacity
167
+
168
+ opacity = Float(value)
169
+ return if opacity.finite? && opacity.between?(0.0, 1.0)
170
+
171
+ raise ArgumentError
172
+ rescue ArgumentError, TypeError
173
+ raise ArgumentError, "#{context} label_opacity must be between 0 and 1" if key == :label_opacity
174
+
175
+ raise
176
+ end
177
+ private_class_method :validate_value
178
+ end
179
+
180
+ STATE_RADIUS = 40
181
+ DEFAULT_STATE_RADIUS = STATE_RADIUS
182
+ DEFAULT_PADDING = 80
183
+ DEFAULT_NODE_SPACING = 120
184
+ DEFAULT_RANK_SPACING = 120
185
+ DEFAULT_FORCE_ITERATIONS = 120
186
+ DEFAULT_GRAPHVIZ_COMMAND = 'dot'
187
+ DEFAULT_PRESERVE_MANUAL_POSITIONS = true
188
+ DEFAULT_FIT = :none
189
+ LAYOUT_OPTIONS = %i[linear circle grid layered bfs force graphviz dot manual].freeze
190
+ DIRECTION_OPTIONS = %i[lr tb rl bt].freeze
191
+ FIT_OPTIONS = %i[none contain cover].freeze
192
+ STATE_KIND_OPTIONS = %i[normal choice fork join].freeze
193
+ INITIAL_POSITION_OPTIONS = %i[auto start].freeze
194
+ FINAL_POSITION_OPTIONS = %i[auto end].freeze
195
+ FORMAT_OPTIONS = %i[svg png pdf webp html mermaid mmd dot plantuml puml].freeze
196
+ FORMAT_ALIASES = {
197
+ mmd: :mermaid,
198
+ puml: :plantuml
199
+ }.freeze
200
+ ALL_EXPORT_CAPABILITIES = %i[
201
+ state_style transition_style url state_tooltip transition_tooltip group parent pseudostate bundle line_style
202
+ ].freeze
203
+ RASTER_EXPORT_CAPABILITIES = (ALL_EXPORT_CAPABILITIES - %i[url state_tooltip transition_tooltip]).freeze
204
+ EXPORTERS = ExporterRegistry.new.tap do |registry|
205
+ registry.register(:svg, extensions: %w[svg], capabilities: ALL_EXPORT_CAPABILITIES) { Exporters::Svg }
206
+ registry.register(:png, extensions: %w[png], binary: true, capabilities: RASTER_EXPORT_CAPABILITIES) { Exporters::Png }
207
+ registry.register(:pdf, extensions: %w[pdf], binary: true, capabilities: RASTER_EXPORT_CAPABILITIES) { Exporters::Pdf }
208
+ registry.register(:webp, extensions: %w[webp], binary: true, capabilities: RASTER_EXPORT_CAPABILITIES) { Exporters::Webp }
209
+ registry.register(:html, extensions: %w[html], capabilities: %i[group parent pseudostate]) { Exporters::Mermaid }
210
+ registry.register(:mermaid, aliases: %i[mmd], extensions: %w[mermaid mmd], capabilities: %i[group parent pseudostate]) { Exporters::Mermaid }
211
+ registry.register(:dot, aliases: %i[gv], extensions: %w[dot gv], capabilities: %i[url state_tooltip transition_tooltip group pseudostate bundle line_style]) { Exporters::Dot }
212
+ registry.register(:plantuml, aliases: %i[puml], extensions: %w[plantuml puml], capabilities: %i[group parent pseudostate]) { Exporters::Plantuml }
213
+ end
214
+ DEFAULT_INITIAL_POSITION = :auto
215
+ DEFAULT_FINAL_POSITION = :auto
216
+ DEFAULT_EPSILON_LABEL = "\u03b5"
217
+ DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024
218
+ DEFAULT_MAX_STATES = 10_000
219
+ DEFAULT_MAX_TRANSITIONS = 100_000
220
+ DEFAULT_MAX_METADATA_DEPTH = 64
221
+ DEFAULT_MAX_LABEL_LENGTH = 64 * 1024
222
+ DEFAULT_MAX_GROUP_DEPTH = 64
223
+ DEFAULT_MAX_CANVAS_AREA = 100_000_000
224
+ DEFAULT_MAX_CANVAS_DIMENSION = 100_000
225
+ DEFAULT_MAX_LAYOUT_ITERATIONS = 10_000
226
+ FORCE_TREE_THRESHOLD = 128
227
+ VALIDATION_MODES = %i[deferred strict].freeze
228
+ VALIDATION_PROFILES = %i[references fsm_semantics dfa].freeze
229
+ UNSET = Object.new.freeze
230
+ EMPTY_TRANSITIONS = [].freeze
231
+
232
+ def self.validate_canvas_dimensions!(width, height)
233
+ { width: width, height: height }.each do |name, value|
234
+ unless value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
235
+ raise ArgumentError, "#{name} must be a positive finite number"
236
+ end
237
+ end
238
+ if width.to_f * height.to_f > DEFAULT_MAX_CANVAS_AREA
239
+ raise ArgumentError, "canvas area exceeds max_canvas_area (#{DEFAULT_MAX_CANVAS_AREA})"
240
+ end
241
+ if width.to_f > DEFAULT_MAX_CANVAS_DIMENSION || height.to_f > DEFAULT_MAX_CANVAS_DIMENSION
242
+ raise ArgumentError, "canvas dimension exceeds max_canvas_dimension (#{DEFAULT_MAX_CANVAS_DIMENSION})"
243
+ end
244
+
245
+ true
246
+ end
247
+ attr_reader :initial_state, :revision
248
+
249
+ def self.png_available?(converter: Exporters::Png::DEFAULT_CONVERTER)
250
+ Exporters::Png.available?(converter: converter)
251
+ end
252
+
253
+ def self.pdf_available?(converter: Exporters::Pdf::DEFAULT_CONVERTER)
254
+ Exporters::Pdf.available?(converter: converter)
255
+ end
256
+
257
+ def self.webp_available?(converter: Exporters::Webp::DEFAULT_CONVERTER)
258
+ Exporters::Webp.available?(converter: converter)
259
+ end
260
+
261
+ def self.register_exporter(name, **options, &loader)
262
+ EXPORTERS.register(name, **options, &loader)
263
+ end
264
+
265
+ def self.exporter_capabilities(format)
266
+ EXPORTERS.fetch(format).capabilities
267
+ end
268
+
269
+ def self.from_hash(data = nil, max_states: DEFAULT_MAX_STATES, max_transitions: DEFAULT_MAX_TRANSITIONS,
270
+ max_metadata_depth: DEFAULT_MAX_METADATA_DEPTH, max_label_length: DEFAULT_MAX_LABEL_LENGTH,
271
+ max_group_depth: DEFAULT_MAX_GROUP_DEPTH, strict_schema: true, **input)
272
+ if data.nil? && !input.empty?
273
+ data = input
274
+ elsif !input.empty?
275
+ raise ArgumentError, "Unknown input keywords: #{input.keys.join(', ')}"
276
+ end
277
+ raise ArgumentError, 'Graphomaton input must be a Hash' unless data.is_a?(Hash)
278
+
279
+ enforce_positive_limit(max_metadata_depth, 'max_metadata_depth')
280
+ enforce_positive_limit(max_label_length, 'max_label_length')
281
+ enforce_positive_limit(max_group_depth, 'max_group_depth')
282
+
283
+ InputPolicy.known_keys!(data, InputPolicy::TOP_LEVEL_KEYS, context: 'top-level', strict: strict_schema)
284
+ ensure_alias_values_agree!(data, :initial, :initial_state, context: 'top-level initial state')
285
+ ensure_alias_values_agree!(data, :final, :final_states, context: 'top-level final states')
286
+ version = input_value(data, :version)
287
+ raise ArgumentError, "Unsupported Graphomaton schema version: #{version.inspect}" unless version.nil? || version == 1
288
+
289
+ automaton = new
290
+ states = state_inputs(input_value(data, :states))
291
+ transitions = transition_inputs(input_value(data, :transitions))
292
+ enforce_collection_limit(states, max_states, 'states')
293
+ enforce_collection_limit(transitions, max_transitions, 'transitions')
294
+
295
+ states.each do |state|
296
+ add_state_from_input(
297
+ automaton,
298
+ state,
299
+ max_metadata_depth: max_metadata_depth,
300
+ max_label_length: max_label_length,
301
+ strict_schema: strict_schema
302
+ )
303
+ end
304
+
305
+ initial_state = input_value(data, :initial, :initial_state)
306
+ assign_initial_from_input(automaton, initial_state) unless initial_state.nil?
307
+
308
+ Array(input_value(data, :final, :final_states)).each do |state|
309
+ automaton.add_final(state)
310
+ end
311
+
312
+ transitions.each do |transition|
313
+ add_transition_from_input(
314
+ automaton,
315
+ transition,
316
+ max_metadata_depth: max_metadata_depth,
317
+ max_label_length: max_label_length,
318
+ strict_schema: strict_schema
319
+ )
320
+ end
321
+
322
+ enforce_group_depth(automaton, max_group_depth)
323
+
324
+ automaton
325
+ end
326
+
327
+ def self.from_json(source, max_input_bytes: DEFAULT_MAX_INPUT_BYTES, **limits)
328
+ from_hash(JSON.parse(bounded_source(source, max_input_bytes)), **limits)
329
+ end
330
+
331
+ def self.from_yaml(source, aliases: false, max_input_bytes: DEFAULT_MAX_INPUT_BYTES, **limits)
332
+ yaml = YAML.safe_load(bounded_source(source, max_input_bytes), permitted_classes: [Symbol], aliases: aliases)
333
+ from_hash(yaml || {}, **limits)
334
+ end
335
+
336
+ def self.theme_from_hash(data)
337
+ raise ArgumentError, 'Graphomaton theme input must be a Hash' unless data.is_a?(Hash)
338
+
339
+ theme = input_value(data, :theme) || data
340
+ Theme.normalize(theme, context: 'Graphomaton theme')
341
+ end
342
+
343
+ def self.theme_from_json(source, max_input_bytes: DEFAULT_MAX_INPUT_BYTES)
344
+ theme_from_hash(JSON.parse(bounded_source(source, max_input_bytes)))
345
+ end
346
+
347
+ def self.theme_from_yaml(source, aliases: false, max_input_bytes: DEFAULT_MAX_INPUT_BYTES)
348
+ yaml = YAML.safe_load(bounded_source(source, max_input_bytes), permitted_classes: [Symbol], aliases: aliases)
349
+ theme_from_hash(yaml || {})
350
+ end
351
+
352
+ def self.add_state_from_input(automaton, input, max_metadata_depth:, max_label_length:, strict_schema:)
353
+ unless input.is_a?(Hash)
354
+ raise ArgumentError, 'State input requires a non-nil id' if input.nil?
355
+ raise ArgumentError, "Duplicate state id: #{input.inspect}" if automaton.state_records.key?(input)
356
+
357
+ automaton.add_state(input, max_metadata_depth: max_metadata_depth, max_label_length: max_label_length)
358
+ return
359
+ end
360
+
361
+ InputPolicy.known_keys!(input, InputPolicy::STATE_KEYS, context: 'state', strict: strict_schema)
362
+ ensure_alias_values_agree!(input, :id, :name, context: 'state id')
363
+ ensure_alias_values_agree!(
364
+ input,
365
+ :final,
366
+ :accepting,
367
+ context: "State #{input_value(input, :id, :name).inspect} final flag"
368
+ )
369
+
370
+ name = input_value(input, :id, :name)
371
+ raise ArgumentError, 'State input requires id or name' if name.nil?
372
+ raise ArgumentError, "Duplicate state id: #{name.inspect}" if automaton.state_records.key?(name)
373
+
374
+ InputPolicy.boolean!(input_value(input, :initial), context: "State #{name.inspect} initial")
375
+ InputPolicy.boolean!(input_value(input, :final, :accepting), context: "State #{name.inspect} final")
376
+ automaton.add_state(
377
+ name,
378
+ input_value(input, :x),
379
+ input_value(input, :y),
380
+ label: input_value(input, :label),
381
+ style: input_value(input, :style),
382
+ metadata: input_value(input, :metadata),
383
+ shape: input_value(input, :shape),
384
+ kind: input_value(input, :kind),
385
+ max_metadata_depth: max_metadata_depth,
386
+ max_label_length: max_label_length
387
+ )
388
+ assign_initial_from_input(automaton, name) if input_value(input, :initial)
389
+ automaton.add_final(name) if input_value(input, :final, :accepting)
390
+ end
391
+ private_class_method :add_state_from_input
392
+
393
+ def self.assign_initial_from_input(automaton, state)
394
+ current = automaton.initial_state
395
+ if !current.nil? && current != state
396
+ raise ArgumentError, "Multiple initial states: #{current.inspect} and #{state.inspect}"
397
+ end
398
+
399
+ automaton.set_initial(state)
400
+ end
401
+ private_class_method :assign_initial_from_input
402
+
403
+ def self.add_transition_from_input(automaton, input, max_metadata_depth:, max_label_length:, strict_schema:)
404
+ if input.is_a?(Array)
405
+ unless input.length == 3 && input.none?(&:nil?)
406
+ raise ArgumentError, 'Transition Array input requires exactly from, to, and label'
407
+ end
408
+
409
+ from, to, label = input
410
+ label = structured_label_from_input(label)
411
+ automaton.add_transition(from, to, label, max_metadata_depth: max_metadata_depth, max_label_length: max_label_length)
412
+ return
413
+ end
414
+
415
+ raise ArgumentError, 'Transition input must be a Hash or Array' unless input.is_a?(Hash)
416
+
417
+ InputPolicy.known_keys!(input, InputPolicy::TRANSITION_KEYS, context: 'transition', strict: strict_schema)
418
+
419
+ from = input_value(input, :from)
420
+ to = input_value(input, :to)
421
+ label = structured_label_from_input(input_value(input, :label))
422
+ raise ArgumentError, 'Transition input requires from, to, and label' if from.nil? || to.nil? || label.nil?
423
+
424
+ automaton.add_transition(
425
+ from,
426
+ to,
427
+ label,
428
+ style: input_value(input, :style),
429
+ metadata: input_value(input, :metadata),
430
+ line_style: input_value(input, :line_style),
431
+ max_metadata_depth: max_metadata_depth,
432
+ max_label_length: max_label_length
433
+ )
434
+ end
435
+ private_class_method :add_transition_from_input
436
+
437
+ def self.structured_label_from_input(label)
438
+ return label unless label.is_a?(Hash)
439
+
440
+ type = input_value(label, :type, :kind)
441
+ raise ArgumentError, 'Structured transition label requires type or kind' unless type
442
+
443
+ case type.to_sym
444
+ when :text
445
+ Label.text(input_value(label, :value, :text))
446
+ when :symbols
447
+ Label.symbols(*Array(input_value(label, :value, :symbols)))
448
+ when :epsilon
449
+ Label.epsilon(input_value(label, :value) || DEFAULT_EPSILON_LABEL)
450
+ when :uml
451
+ value = input_value(label, :value)
452
+ value = label unless value.is_a?(Hash)
453
+ Label.uml(
454
+ event: input_value(value, :event),
455
+ guard: input_value(value, :guard),
456
+ action: input_value(value, :action)
457
+ )
458
+ when :alternatives
459
+ values = Array(input_value(label, :value))
460
+ Label.alternatives(*values.map { |value| structured_label_from_input(value) })
461
+ else
462
+ raise ArgumentError, "Unknown label type: #{type.inspect}"
463
+ end
464
+ end
465
+ private_class_method :structured_label_from_input
466
+
467
+ def self.state_inputs(input)
468
+ return [] if input.nil?
469
+ return input if input.is_a?(Array)
470
+ raise ArgumentError, 'States input must be an Array or Hash' unless input.is_a?(Hash)
471
+
472
+ input.map do |name, attributes|
473
+ next name if attributes.nil?
474
+ raise ArgumentError, "State #{name.inspect} attributes must be a Hash" unless attributes.is_a?(Hash)
475
+
476
+ ensure_alias_values_agree!(attributes, :id, :name, context: "State #{name.inspect} id")
477
+ explicit_name = input_value(attributes, :id, :name)
478
+ if !explicit_name.nil? && explicit_name != name
479
+ raise ArgumentError, "State map key #{name.inspect} conflicts with id #{explicit_name.inspect}"
480
+ end
481
+
482
+ explicit_name.nil? ? attributes.merge(id: name) : attributes
483
+ end
484
+ end
485
+ private_class_method :state_inputs
486
+
487
+ def self.ensure_alias_values_agree!(hash, *keys, context:)
488
+ values = keys.filter_map do |key|
489
+ if hash.key?(key)
490
+ [key, hash[key]]
491
+ elsif hash.key?(key.to_s)
492
+ [key, hash[key.to_s]]
493
+ end
494
+ end
495
+ return if values.size < 2 || values.map(&:last).uniq.size == 1
496
+
497
+ details = values.map { |key, value| "#{key}=#{value.inspect}" }.join(', ')
498
+ raise ArgumentError, "Conflicting #{context}: #{details}"
499
+ end
500
+ private_class_method :ensure_alias_values_agree!
501
+
502
+ def self.transition_inputs(input)
503
+ return [] if input.nil?
504
+ raise ArgumentError, 'Transitions input must be an Array' unless input.is_a?(Array)
505
+
506
+ input
507
+ end
508
+ private_class_method :transition_inputs
509
+
510
+ def self.bounded_source(source, max_input_bytes)
511
+ enforce_positive_limit(max_input_bytes, 'max_input_bytes')
512
+ text = source.respond_to?(:read) ? read_bounded_io(source, max_input_bytes) : source.to_s
513
+ if text.bytesize > max_input_bytes
514
+ raise ArgumentError, "Graphomaton input exceeds max_input_bytes (#{max_input_bytes})"
515
+ end
516
+
517
+ text
518
+ end
519
+ private_class_method :bounded_source
520
+
521
+ def self.read_bounded_io(source, max_input_bytes)
522
+ output = String.new(encoding: Encoding::BINARY)
523
+ while output.bytesize <= max_input_bytes
524
+ chunk = source.read([16 * 1024, max_input_bytes + 1 - output.bytesize].min)
525
+ break if chunk.nil? || chunk.empty?
526
+
527
+ output << chunk
528
+ end
529
+ output
530
+ end
531
+ private_class_method :read_bounded_io
532
+
533
+ def self.enforce_group_depth(automaton, maximum)
534
+ enforce_positive_limit(maximum, 'max_group_depth')
535
+ automaton.state_records.each_key do |state|
536
+ depth = 0
537
+ current = state
538
+ seen = {}
539
+ while current
540
+ break if seen[current]
541
+
542
+ seen[current] = true
543
+ metadata = automaton.state_records[current]&.fetch(:metadata, nil)
544
+ current = metadata.is_a?(Hash) ? input_value(metadata, :parent) : nil
545
+ depth += 1 if current
546
+ raise ArgumentError, "State hierarchy exceeds max_group_depth (#{maximum})" if depth > maximum
547
+ end
548
+ end
549
+ end
550
+ private_class_method :enforce_group_depth
551
+
552
+ def self.enforce_collection_limit(collection, limit, name)
553
+ enforce_positive_limit(limit, "max_#{name}")
554
+ return if collection.size <= limit
555
+
556
+ raise ArgumentError, "Graphomaton input exceeds max_#{name} (#{limit})"
557
+ end
558
+ private_class_method :enforce_collection_limit
559
+
560
+ def self.enforce_positive_limit(limit, name)
561
+ return if limit.is_a?(Integer) && limit.positive?
562
+
563
+ raise ArgumentError, "#{name} must be a positive Integer"
564
+ end
565
+ private_class_method :enforce_positive_limit
566
+
567
+ def self.input_value(hash, *keys)
568
+ keys.each do |key|
569
+ return hash[key] if hash.key?(key)
570
+ return hash[key.to_s] if hash.key?(key.to_s)
571
+ end
572
+
573
+ nil
574
+ end
575
+ private_class_method :input_value
576
+
577
+ def initialize(validation: :deferred)
578
+ @validation_mode = validation.to_sym
579
+ unless VALIDATION_MODES.include?(@validation_mode)
580
+ raise ArgumentError, "Unknown validation mode: #{validation.inspect}. Available modes: #{VALIDATION_MODES.join(', ')}"
581
+ end
582
+
10
583
  @states = {}
11
584
  @transitions = []
12
585
  @initial_state = nil
13
586
  @final_states = []
587
+ @final_state_set = Set.new
14
588
  @state_positions = {}
589
+ @manual_states = {}
590
+ @revision = 0
591
+ @next_transition_id = 0
592
+ @layout_cache = {}
593
+ end
594
+
595
+ def states
596
+ immutable_snapshot(@states.transform_values(&:to_h))
597
+ end
598
+
599
+ def transitions
600
+ immutable_snapshot(@transitions.map(&:to_h))
601
+ end
602
+
603
+ def final_states
604
+ immutable_snapshot(@final_states)
605
+ end
606
+
607
+ def state_records
608
+ @states.dup.freeze
609
+ end
610
+
611
+ def transition_records
612
+ @transitions.dup.freeze
613
+ end
614
+
615
+ def add_state(name, x = nil, y = nil, label: nil, style: nil, metadata: nil, shape: nil, kind: nil,
616
+ max_metadata_depth: DEFAULT_MAX_METADATA_DEPTH, max_label_length: DEFAULT_MAX_LABEL_LENGTH)
617
+ InputPolicy.identifier!(name, context: 'State id')
618
+ label = label.to_s if label.is_a?(Label)
619
+ InputPolicy.label!(label, context: "State #{name.inspect} label", max_bytes: max_label_length)
620
+ InputPolicy.mapping!(style, context: "State #{name.inspect} style")
621
+ InputPolicy.mapping!(metadata, context: "State #{name.inspect} metadata")
622
+ if metadata
623
+ InputPolicy.nested_depth!(
624
+ metadata,
625
+ maximum: max_metadata_depth,
626
+ context: "State #{name.inspect} metadata",
627
+ max_string_bytes: max_label_length
628
+ )
629
+ end
630
+ raise ArgumentError, "Duplicate state id: #{name.inspect}" if @states.key?(name)
631
+ if x.nil? != y.nil?
632
+ raise ArgumentError, 'State coordinates require both x and y'
633
+ end
634
+ unless x.nil?
635
+ validate_finite_number!(x, 'state x coordinate')
636
+ validate_finite_number!(y, 'state y coordinate')
637
+ end
638
+
639
+ stable_name = immutable_copy(name)
640
+ @manual_states[stable_name] = !x.nil? && !y.nil?
641
+ @states[stable_name] = State.new(
642
+ id: stable_name,
643
+ x: x,
644
+ y: y,
645
+ label: immutable_copy(label),
646
+ style: immutable_copy(style),
647
+ metadata: immutable_copy(metadata),
648
+ shape: immutable_copy(shape),
649
+ kind: resolve_state_kind(kind)
650
+ )
651
+ graph_changed!
652
+ self
653
+ end
654
+
655
+ def upsert_state(name, x = UNSET, y = UNSET, **attributes)
656
+ unless @states.key?(name)
657
+ new_x = x.equal?(UNSET) ? nil : x
658
+ new_y = y.equal?(UNSET) ? nil : y
659
+ return add_state(name, new_x, new_y, **attributes)
660
+ end
661
+
662
+ return update_state(name, **attributes) if x.equal?(UNSET) && y.equal?(UNSET)
663
+ if x.equal?(UNSET) || y.equal?(UNSET)
664
+ raise ArgumentError, 'State coordinates require both x and y'
665
+ end
666
+
667
+ update_state(name, x: x, y: y, **attributes)
668
+ end
669
+
670
+ def update_state(name, **attributes)
671
+ state = @states.fetch(name) { raise ArgumentError, "State is not defined: #{name.inspect}" }
672
+ allowed = %i[x y label style metadata shape kind]
673
+ unknown = attributes.keys - allowed
674
+ raise ArgumentError, "Unknown state attributes: #{unknown.join(', ')}" unless unknown.empty?
675
+ if attributes.key?(:x) != attributes.key?(:y)
676
+ raise ArgumentError, 'State coordinates require both x and y'
677
+ end
678
+
679
+ x = attributes.fetch(:x, state.x)
680
+ y = attributes.fetch(:y, state.y)
681
+ if x.nil? != y.nil?
682
+ raise ArgumentError, 'State coordinates require both x and y'
683
+ end
684
+ unless x.nil?
685
+ validate_finite_number!(x, 'state x coordinate')
686
+ validate_finite_number!(y, 'state y coordinate')
687
+ end
688
+ label = attributes.fetch(:label, state.label)
689
+ label = label.to_s if label.is_a?(Label)
690
+ style = attributes.fetch(:style, state.style)
691
+ metadata = attributes.fetch(:metadata, state.metadata)
692
+ InputPolicy.label!(label, context: "State #{name.inspect} label", max_bytes: DEFAULT_MAX_LABEL_LENGTH)
693
+ InputPolicy.mapping!(style, context: "State #{name.inspect} style")
694
+ InputPolicy.mapping!(metadata, context: "State #{name.inspect} metadata")
695
+ if metadata
696
+ InputPolicy.nested_depth!(
697
+ metadata,
698
+ maximum: DEFAULT_MAX_METADATA_DEPTH,
699
+ context: "State #{name.inspect} metadata",
700
+ max_string_bytes: DEFAULT_MAX_LABEL_LENGTH
701
+ )
702
+ end
703
+
704
+ updated_state = State.new(
705
+ id: state.id,
706
+ x: x,
707
+ y: y,
708
+ label: immutable_copy(label),
709
+ style: immutable_copy(style),
710
+ metadata: immutable_copy(metadata),
711
+ shape: immutable_copy(attributes.fetch(:shape, state.shape)),
712
+ kind: resolve_state_kind(attributes.fetch(:kind, state.kind))
713
+ )
714
+ coordinates_updated = attributes.key?(:x)
715
+ manual = !x.nil? && !y.nil?
716
+ manual_changed = coordinates_updated && @manual_states[name] != manual
717
+ return self if updated_state == state && !manual_changed
718
+
719
+ @states[name] = updated_state
720
+ @manual_states[name] = manual if coordinates_updated
721
+ graph_changed!
722
+ self
723
+ end
724
+
725
+ def remove_state(name, cascade: false)
726
+ raise ArgumentError, "State is not defined: #{name.inspect}" unless @states.key?(name)
727
+
728
+ connected = @transitions.select { |transition| transition.from == name || transition.to == name }
729
+ if connected.any? && !cascade
730
+ raise ArgumentError, "State #{name.inspect} has transitions; pass cascade: true to remove them"
731
+ end
732
+
733
+ @states.delete(name)
734
+ @manual_states.delete(name)
735
+ @state_positions.delete(name)
736
+ @transitions -= connected
737
+ @initial_state = nil if @initial_state == name
738
+ if @final_state_set.delete?(name)
739
+ @final_states.delete(name)
740
+ end
741
+ graph_changed!
742
+ self
743
+ end
744
+
745
+ def add_transition(from, to, label, style: nil, metadata: nil, line_style: nil,
746
+ epsilon_label: DEFAULT_EPSILON_LABEL, sort_labels: false,
747
+ max_metadata_depth: DEFAULT_MAX_METADATA_DEPTH, max_label_length: DEFAULT_MAX_LABEL_LENGTH)
748
+ InputPolicy.identifier!(from, context: 'Transition source')
749
+ InputPolicy.identifier!(to, context: 'Transition target')
750
+ raise ArgumentError, 'Transition label cannot be nil' if label.nil?
751
+ labels = label.is_a?(Array) ? label : [label]
752
+ raise ArgumentError, 'Transition labels cannot be empty' if labels.empty?
753
+ raise ArgumentError, 'Transition labels cannot contain nil' if labels.any?(&:nil?)
754
+ labels.each do |item|
755
+ InputPolicy.label!(item, context: 'Transition label', max_bytes: max_label_length)
756
+ end
757
+ InputPolicy.mapping!(style, context: 'Transition style')
758
+ InputPolicy.mapping!(metadata, context: 'Transition metadata')
759
+ if metadata
760
+ InputPolicy.nested_depth!(
761
+ metadata,
762
+ maximum: max_metadata_depth,
763
+ context: 'Transition metadata',
764
+ max_string_bytes: max_label_length
765
+ )
766
+ end
767
+ if @validation_mode == :strict
768
+ raise ValidationError, "Transition source #{from.inspect} is not defined" unless @states.key?(from)
769
+ raise ValidationError, "Transition target #{to.inspect} is not defined" unless @states.key?(to)
770
+ end
771
+ @next_transition_id += 1
772
+ @transitions << Transition.new(
773
+ id: @next_transition_id,
774
+ from: immutable_copy(from),
775
+ to: immutable_copy(to),
776
+ label: immutable_copy(normalize_transition_label(label, epsilon_label: epsilon_label, sort_labels: sort_labels)),
777
+ style: immutable_copy(style),
778
+ metadata: immutable_copy(metadata),
779
+ line_style: immutable_copy(line_style)
780
+ )
781
+ graph_changed!
782
+ self
15
783
  end
16
784
 
17
- def add_state(name, x = nil, y = nil)
18
- @states[name] = { name: name, x: x, y: y }
19
- name
785
+ def update_transition(identifier, **attributes)
786
+ index = transition_index(identifier)
787
+ transition = @transitions.fetch(index)
788
+ allowed = %i[from to label style metadata line_style]
789
+ unknown = attributes.keys - allowed
790
+ raise ArgumentError, "Unknown transition attributes: #{unknown.join(', ')}" unless unknown.empty?
791
+
792
+ from = attributes.fetch(:from, transition.from)
793
+ to = attributes.fetch(:to, transition.to)
794
+ label = attributes.fetch(:label, transition.label)
795
+ InputPolicy.identifier!(from, context: 'Transition source')
796
+ InputPolicy.identifier!(to, context: 'Transition target')
797
+ raise ArgumentError, 'Transition label cannot be nil' if label.nil?
798
+ labels = label.is_a?(Array) ? label : [label]
799
+ raise ArgumentError, 'Transition labels cannot be empty' if labels.empty?
800
+ raise ArgumentError, 'Transition labels cannot contain nil' if labels.any?(&:nil?)
801
+ labels.each do |item|
802
+ InputPolicy.label!(item, context: 'Transition label', max_bytes: DEFAULT_MAX_LABEL_LENGTH)
803
+ end
804
+ if @validation_mode == :strict
805
+ raise ValidationError, "Transition source #{from.inspect} is not defined" unless @states.key?(from)
806
+ raise ValidationError, "Transition target #{to.inspect} is not defined" unless @states.key?(to)
807
+ end
808
+ metadata = attributes.fetch(:metadata, transition.metadata)
809
+ style = attributes.fetch(:style, transition.style)
810
+ InputPolicy.mapping!(style, context: 'Transition style')
811
+ InputPolicy.mapping!(metadata, context: 'Transition metadata')
812
+ InputPolicy.nested_depth!(metadata, maximum: DEFAULT_MAX_METADATA_DEPTH, context: 'Transition metadata') if metadata
813
+
814
+ updated_transition = Transition.new(
815
+ id: transition.id,
816
+ from: immutable_copy(from),
817
+ to: immutable_copy(to),
818
+ label: immutable_copy(normalize_transition_label(label)),
819
+ style: immutable_copy(style),
820
+ metadata: immutable_copy(metadata),
821
+ line_style: immutable_copy(attributes.fetch(:line_style, transition.line_style))
822
+ )
823
+ return self if updated_transition == transition
824
+
825
+ @transitions[index] = updated_transition
826
+ graph_changed!
827
+ self
20
828
  end
21
829
 
22
- def add_transition(from, to, label)
23
- @transitions << { from: from, to: to, label: label }
830
+ def remove_transition(identifier)
831
+ @transitions.delete_at(transition_index(identifier))
832
+ graph_changed!
833
+ self
24
834
  end
25
835
 
26
836
  def set_initial(state)
27
- @initial_state = state
837
+ InputPolicy.identifier!(state, context: 'Initial state id')
838
+ raise ValidationError, "Initial state #{state.inspect} is not defined" if @validation_mode == :strict && !@states.key?(state)
839
+
840
+ stable_state = immutable_copy(state)
841
+ return self if @initial_state == stable_state
842
+
843
+ @initial_state = stable_state
844
+ graph_changed!
845
+ self
846
+ end
847
+
848
+ def clear_initial
849
+ return self if @initial_state.nil?
850
+
851
+ @initial_state = nil
852
+ graph_changed!
853
+ self
28
854
  end
29
855
 
30
856
  def add_final(state)
31
- @final_states << state unless @final_states.include?(state)
857
+ InputPolicy.identifier!(state, context: 'Final state id')
858
+ raise ValidationError, "Final state #{state.inspect} is not defined" if @validation_mode == :strict && !@states.key?(state)
859
+ return self if @final_state_set.include?(state)
860
+
861
+ stable_state = immutable_copy(state)
862
+ @final_states << stable_state
863
+ @final_state_set << stable_state
864
+ graph_changed!
865
+ self
32
866
  end
33
867
 
34
- def auto_layout(width = 800, height = 600)
35
- return if @states.empty?
868
+ def remove_final(state)
869
+ return self unless @final_state_set.delete?(state)
36
870
 
37
- ordered_states = []
38
- ordered_states << @initial_state if @initial_state && @states[@initial_state]
871
+ @final_states.delete(state)
872
+ graph_changed!
873
+ self
874
+ end
39
875
 
40
- @states.each_key do |name|
41
- ordered_states << name unless ordered_states.include?(name)
876
+ def validation_diagnostics(profile: :references)
877
+ profiles = Array(profile).map(&:to_sym)
878
+ profiles = profiles.flat_map { |name| name == :all ? VALIDATION_PROFILES : name }.uniq
879
+ unknown = profiles - VALIDATION_PROFILES
880
+ unless unknown.empty?
881
+ raise ArgumentError, "Unknown validation profiles: #{unknown.join(', ')}. Available profiles: #{VALIDATION_PROFILES.join(', ')}"
42
882
  end
883
+ diagnostics = reference_diagnostics if profiles.include?(:references)
884
+ diagnostics ||= []
885
+ diagnostics.concat(fsm_semantic_diagnostics) if profiles.include?(:fsm_semantics)
886
+ diagnostics.concat(dfa_diagnostics) if profiles.include?(:dfa)
887
+ diagnostics.freeze
888
+ end
889
+
890
+ def validation_errors(profile: :references)
891
+ validation_diagnostics(profile: profile)
892
+ .select { |diagnostic| diagnostic.severity == :error }
893
+ .map(&:message)
894
+ end
895
+
896
+ def analysis_warnings
897
+ validation_diagnostics(profile: :fsm_semantics)
898
+ .select { |diagnostic| diagnostic.severity == :warning }
899
+ .map(&:message)
900
+ end
901
+
902
+ def valid?(profile: :references)
903
+ validation_errors(profile: profile).empty?
904
+ end
905
+
906
+ def validate!(profile: :references)
907
+ errors = validation_errors(profile: profile)
908
+ return true if errors.empty?
909
+
910
+ raise ValidationError, errors.join("\n")
911
+ end
912
+
913
+ def reachable_states
914
+ layered_distances.keys
915
+ end
43
916
 
44
- margin = 100
45
- spacing = (width - (2 * margin)) / [ordered_states.size - 1, 1].max
46
- y_center = height / 2
917
+ def reachable_from(state)
918
+ raise ArgumentError, "State is not defined: #{state.inspect}" unless @states.key?(state)
47
919
 
48
- ordered_states.each_with_index do |name, index|
49
- if @states[name][:x].nil? || @states[name][:y].nil?
50
- @states[name][:x] = margin + (index * spacing)
51
- @states[name][:y] = y_center
920
+ ensure_analysis_index!
921
+ visited = { state => true }
922
+ queue = [state]
923
+ head = 0
924
+ while head < queue.length
925
+ current = queue[head]
926
+ head += 1
927
+ @outgoing_by_state[current].each do |transition|
928
+ target = transition.to
929
+ next if visited[target]
930
+
931
+ visited[target] = true
932
+ queue << target
52
933
  end
53
934
  end
935
+ ordered_state_names.select { |name| visited[name] }
54
936
  end
55
937
 
56
- def count_parallel_transitions(from, to)
57
- count = 0
58
- @transitions.each do |trans|
59
- if (trans[:from] == from && trans[:to] == to) ||
60
- (trans[:from] == to && trans[:to] == from)
61
- count += 1
938
+ alias reachable_from_initial reachable_states
939
+
940
+ def graph_roots
941
+ ensure_analysis_index!
942
+ ordered_state_names.select { |state| @incoming_by_state[state].empty? }
943
+ end
944
+
945
+ def weakly_connected_components
946
+ weak_components(ordered_state_names)
947
+ end
948
+
949
+ def unreachable_states
950
+ @states.keys - reachable_states
951
+ end
952
+
953
+ def states_reaching_final
954
+ defined_final_states = @final_states.select { |state| @states.key?(state) }
955
+ return [] if defined_final_states.empty?
956
+
957
+ ensure_analysis_index!
958
+
959
+ reachable = defined_final_states.to_h { |state| [state, true] }
960
+ queue = reachable.keys
961
+ head = 0
962
+ while head < queue.length
963
+ state = queue[head]
964
+ head += 1
965
+ @incoming_by_state[state].each do |transition|
966
+ previous = transition.from
967
+ next if reachable[previous]
968
+
969
+ reachable[previous] = true
970
+ queue << previous
62
971
  end
63
972
  end
64
- count
973
+
974
+ ordered_state_names.select { |state| reachable[state] }
65
975
  end
66
976
 
67
- def get_transition_index(from, to, label)
68
- index = 0
69
- @transitions.each do |trans|
70
- next unless (trans[:from] == from && trans[:to] == to) ||
71
- (trans[:from] == to && trans[:to] == from)
72
- return index if trans[:from] == from && trans[:to] == to && trans[:label] == label
977
+ def dead_states
978
+ reaching_final = states_reaching_final
979
+ return [] if reaching_final.empty?
73
980
 
74
- index += 1
75
- end
76
- index
981
+ @states.keys - reaching_final
982
+ end
983
+
984
+ def live_states
985
+ states_reaching_final
77
986
  end
78
987
 
79
- def to_svg(width = 800, height = 600)
80
- Exporters::Svg.new(self).export(width, height)
988
+ def trap_states
989
+ self_loop_traps
81
990
  end
82
991
 
83
- def save_svg(filename, width = 800, height = 600)
84
- File.write(filename, to_svg(width, height))
992
+ def self_loop_traps
993
+ ensure_analysis_index!
994
+ ordered_state_names.select do |state|
995
+ outgoing = @outgoing_by_state[state]
996
+ next false if outgoing.empty?
997
+
998
+ outgoing.all? { |transition| transition.to == state }
999
+ end
85
1000
  end
86
1001
 
87
- def to_mermaid
88
- Exporters::Mermaid.new(self).export
1002
+ def sink_states
1003
+ ensure_analysis_index!
1004
+ ordered_state_names.select { |state| @outgoing_by_state[state].empty? }
89
1005
  end
90
1006
 
91
- def save_html(filename)
92
- File.write(filename, Exporters::Mermaid.new(self).export_html)
1007
+ def bottom_sccs
1008
+ ensure_analysis_index!
1009
+ strongly_connected_components.select do |component|
1010
+ members = component.to_set
1011
+ component.all? { |state| @outgoing_by_state[state].all? { |transition| members.include?(transition.to) } }
1012
+ end
93
1013
  end
94
1014
 
95
- def to_dot
96
- Exporters::Dot.new(self).export
1015
+ def strongly_connected_components
1016
+ ensure_analysis_index!
1017
+ adjacency = @outgoing_by_state.transform_values { |transitions| transitions.map(&:to) }
1018
+ reverse_adjacency = @incoming_by_state.transform_values { |transitions| transitions.map(&:from) }
1019
+
1020
+ visited = {}
1021
+ finish_order = []
1022
+ @states.each_key do |state|
1023
+ next if visited[state]
1024
+
1025
+ visited[state] = true
1026
+ stack = [[state, 0]]
1027
+ until stack.empty?
1028
+ current, next_index = stack.last
1029
+ if next_index < adjacency[current].length
1030
+ target = adjacency[current][next_index]
1031
+ stack.last[1] += 1
1032
+ next if visited[target]
1033
+
1034
+ visited[target] = true
1035
+ stack << [target, 0]
1036
+ else
1037
+ finish_order << current
1038
+ stack.pop
1039
+ end
1040
+ end
1041
+ end
1042
+
1043
+ assigned = {}
1044
+ finish_order.reverse_each.filter_map do |state|
1045
+ next if assigned[state]
1046
+
1047
+ component = []
1048
+ stack = [state]
1049
+ assigned[state] = true
1050
+ until stack.empty?
1051
+ current = stack.pop
1052
+ component << current
1053
+ reverse_adjacency[current].reverse_each do |target|
1054
+ next if assigned[target]
1055
+
1056
+ assigned[target] = true
1057
+ stack << target
1058
+ end
1059
+ end
1060
+ component
1061
+ end
97
1062
  end
98
1063
 
99
- def save_dot(filename)
100
- File.write(filename, to_dot)
1064
+ def layout_warnings(width = 800, height = 600, layout: :linear, direction: :lr,
1065
+ state_radius: DEFAULT_STATE_RADIUS, padding: DEFAULT_PADDING,
1066
+ node_spacing: DEFAULT_NODE_SPACING, rank_spacing: DEFAULT_RANK_SPACING,
1067
+ force_iterations: DEFAULT_FORCE_ITERATIONS, layout_seed: nil,
1068
+ graphviz_command: DEFAULT_GRAPHVIZ_COMMAND,
1069
+ initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
1070
+ preserve_manual_positions: DEFAULT_PRESERVE_MANUAL_POSITIONS,
1071
+ fit: DEFAULT_FIT)
1072
+ positions = layout_positions(
1073
+ width,
1074
+ height,
1075
+ layout: layout,
1076
+ direction: direction,
1077
+ state_radius: state_radius,
1078
+ padding: padding,
1079
+ node_spacing: node_spacing,
1080
+ rank_spacing: rank_spacing,
1081
+ force_iterations: force_iterations,
1082
+ layout_seed: layout_seed,
1083
+ graphviz_command: graphviz_command,
1084
+ initial_position: initial_position,
1085
+ final_position: final_position,
1086
+ preserve_manual_positions: preserve_manual_positions,
1087
+ fit: fit
1088
+ )
1089
+
1090
+ layout_diagnostics_for(positions, width, height, state_radius).map(&:message)
101
1091
  end
102
1092
 
103
- def to_plantuml
104
- Exporters::Plantuml.new(self).export
1093
+ def layout_diagnostics_for(positions, width, height, state_radius, x: 0, y: 0)
1094
+ radius = state_radius.to_f
1095
+ left = x.to_f
1096
+ top = y.to_f
1097
+ right = left + width.to_f
1098
+ bottom = top + height.to_f
1099
+ positions.each_with_object([]) do |(name, position), diagnostics|
1100
+ state_x = position[:x].to_f
1101
+ state_y = position[:y].to_f
1102
+ if state_x - radius < left || state_x + radius > right
1103
+ diagnostics << Diagnostic.new(
1104
+ code: 'state-clipped-horizontal',
1105
+ severity: :warning,
1106
+ path: ['states', name, 'x'],
1107
+ message: "State #{name.inspect} may be clipped horizontally",
1108
+ hint: 'Increase the canvas width or use fit: :contain'
1109
+ )
1110
+ end
1111
+ if state_y - radius < top || state_y + radius > bottom
1112
+ diagnostics << Diagnostic.new(
1113
+ code: 'state-clipped-vertical',
1114
+ severity: :warning,
1115
+ path: ['states', name, 'y'],
1116
+ message: "State #{name.inspect} may be clipped vertically",
1117
+ hint: 'Increase the canvas height or use fit: :contain'
1118
+ )
1119
+ end
1120
+ end.freeze
105
1121
  end
106
1122
 
107
- def save_plantuml(filename)
108
- File.write(filename, to_plantuml)
1123
+ def layout_positions(width = 800, height = 600, layout: :linear, direction: :lr,
1124
+ state_radius: DEFAULT_STATE_RADIUS, padding: DEFAULT_PADDING,
1125
+ node_spacing: DEFAULT_NODE_SPACING, rank_spacing: DEFAULT_RANK_SPACING,
1126
+ force_iterations: DEFAULT_FORCE_ITERATIONS, layout_seed: nil,
1127
+ graphviz_command: DEFAULT_GRAPHVIZ_COMMAND,
1128
+ initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
1129
+ preserve_manual_positions: DEFAULT_PRESERVE_MANUAL_POSITIONS,
1130
+ fit: DEFAULT_FIT)
1131
+ self.class.validate_canvas_dimensions!(width, height)
1132
+ validate_finite_number!(state_radius, 'state_radius', positive: true)
1133
+ validate_finite_number!(padding, 'padding', nonnegative: true)
1134
+ validate_finite_number!(node_spacing, 'node_spacing', nonnegative: true)
1135
+ validate_finite_number!(rank_spacing, 'rank_spacing', nonnegative: true)
1136
+ unless force_iterations.is_a?(Integer) && force_iterations >= 0
1137
+ raise ArgumentError, 'force_iterations must be a non-negative Integer'
1138
+ end
1139
+ if force_iterations > DEFAULT_MAX_LAYOUT_ITERATIONS
1140
+ raise ArgumentError, "force_iterations exceeds max_layout_iterations (#{DEFAULT_MAX_LAYOUT_ITERATIONS})"
1141
+ end
1142
+ unless layout_seed.nil? || layout_seed.is_a?(Integer)
1143
+ raise ArgumentError, 'layout_seed must be an Integer or nil'
1144
+ end
1145
+
1146
+ return {} if @states.empty?
1147
+
1148
+ resolved_layout = resolve_layout(layout)
1149
+ resolved_direction = resolve_direction(direction)
1150
+ resolved_fit = resolve_fit(fit)
1151
+ resolved_initial_position = resolve_initial_position(initial_position)
1152
+ resolved_final_position = resolve_final_position(final_position)
1153
+ resolved_padding = [padding.to_f, 0].max
1154
+ resolved_node_spacing = [node_spacing.to_f, (state_radius * 2.5)].max
1155
+ resolved_rank_spacing = [rank_spacing.to_f, (state_radius * 2.5)].max
1156
+ effective_preserve_manual_positions = preserve_manual_positions || resolved_layout == :manual
1157
+ cache_key = [
1158
+ width.to_f, height.to_f, resolved_layout, resolved_direction, state_radius.to_f, resolved_padding,
1159
+ resolved_node_spacing, resolved_rank_spacing, force_iterations, layout_seed,
1160
+ Array(graphviz_command), resolved_initial_position, resolved_final_position,
1161
+ effective_preserve_manual_positions, resolved_fit
1162
+ ].freeze
1163
+ if (cached = @layout_cache[cache_key])
1164
+ positions = deep_copy(cached)
1165
+ @state_positions = positions
1166
+ return positions
1167
+ end
1168
+ ordered_states = ordered_state_names
1169
+
1170
+ manual_positions = {}
1171
+ auto_states = []
1172
+
1173
+ ordered_states.each do |name|
1174
+ state = @states[name]
1175
+ if effective_preserve_manual_positions && manual_position?(name)
1176
+ validate_finite_number!(state[:x], "state #{name.inspect} x coordinate")
1177
+ validate_finite_number!(state[:y], "state #{name.inspect} y coordinate")
1178
+ manual_positions[name] = { x: state[:x], y: state[:y] }
1179
+ else
1180
+ auto_states << name
1181
+ end
1182
+ end
1183
+
1184
+ auto_states = arrange_auto_states(
1185
+ auto_states,
1186
+ initial_position: resolved_initial_position,
1187
+ final_position: resolved_final_position
1188
+ )
1189
+
1190
+ auto_positions = case resolved_layout
1191
+ when :linear
1192
+ layout_linear_positions(auto_states, width, height, resolved_direction, state_radius,
1193
+ resolved_padding, resolved_node_spacing)
1194
+ when :circle
1195
+ layout_circle_positions(auto_states, width, height, resolved_direction, state_radius, resolved_padding)
1196
+ when :grid
1197
+ layout_grid_positions(auto_states, width, height, resolved_direction, state_radius,
1198
+ resolved_padding, resolved_node_spacing)
1199
+ when :layered, :bfs
1200
+ layout_layered_positions(auto_states, width, height, resolved_direction, state_radius,
1201
+ resolved_padding, resolved_node_spacing, resolved_rank_spacing,
1202
+ final_position: resolved_final_position)
1203
+ when :manual
1204
+ if auto_states.empty?
1205
+ {}
1206
+ else
1207
+ raise ArgumentError, "Manual layout requires explicit coordinates for: #{auto_states.join(', ')}"
1208
+ end
1209
+ when :force
1210
+ layout_force_positions(
1211
+ auto_states,
1212
+ width,
1213
+ height,
1214
+ resolved_direction,
1215
+ state_radius,
1216
+ resolved_padding,
1217
+ resolved_node_spacing,
1218
+ force_iterations,
1219
+ layout_seed,
1220
+ fixed_positions: manual_positions
1221
+ )
1222
+ when :graphviz, :dot
1223
+ layout_graphviz_positions(
1224
+ auto_states,
1225
+ width,
1226
+ height,
1227
+ resolved_direction,
1228
+ state_radius,
1229
+ resolved_padding,
1230
+ command: graphviz_command
1231
+ )
1232
+ else
1233
+ raise ArgumentError, "Unknown SVG layout: #{layout.inspect}. Available layouts: #{LAYOUT_OPTIONS.join(', ')}"
1234
+ end
1235
+
1236
+ if resolved_layout != :force
1237
+ auto_positions = avoid_fixed_position_collisions(
1238
+ auto_positions,
1239
+ manual_positions,
1240
+ width,
1241
+ height,
1242
+ state_radius,
1243
+ resolved_padding,
1244
+ resolved_node_spacing,
1245
+ resolved_direction
1246
+ )
1247
+ end
1248
+ positions = manual_positions.merge(auto_positions)
1249
+ positions = fit_positions(positions, width, height, state_radius, resolved_padding, resolved_fit) unless resolved_fit == :none
1250
+ @state_positions = positions
1251
+ @layout_cache.shift if @layout_cache.size >= 16
1252
+ @layout_cache[cache_key] = immutable_copy(positions)
1253
+ positions
1254
+ end
1255
+
1256
+ def ordered_state_names
1257
+ states = @states.keys
1258
+ return states unless @states.key?(@initial_state)
1259
+
1260
+ states.delete(@initial_state)
1261
+ states.unshift(@initial_state)
1262
+ end
1263
+
1264
+ def auto_layout(width = 800, height = 600, layout: :linear, direction: :lr,
1265
+ state_radius: DEFAULT_STATE_RADIUS, padding: DEFAULT_PADDING,
1266
+ node_spacing: DEFAULT_NODE_SPACING, rank_spacing: DEFAULT_RANK_SPACING,
1267
+ force_iterations: DEFAULT_FORCE_ITERATIONS, layout_seed: nil,
1268
+ graphviz_command: DEFAULT_GRAPHVIZ_COMMAND,
1269
+ initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
1270
+ preserve_manual_positions: DEFAULT_PRESERVE_MANUAL_POSITIONS,
1271
+ fit: DEFAULT_FIT)
1272
+ return self if @states.empty?
1273
+
1274
+ resolved_layout = resolve_layout(layout)
1275
+ effective_preserve_manual_positions = preserve_manual_positions || resolved_layout == :manual
1276
+
1277
+ layout_positions(
1278
+ width,
1279
+ height,
1280
+ layout: resolved_layout,
1281
+ direction: direction,
1282
+ state_radius: state_radius,
1283
+ padding: padding,
1284
+ node_spacing: node_spacing,
1285
+ rank_spacing: rank_spacing,
1286
+ force_iterations: force_iterations,
1287
+ layout_seed: layout_seed,
1288
+ graphviz_command: graphviz_command,
1289
+ initial_position: initial_position,
1290
+ final_position: final_position,
1291
+ preserve_manual_positions: effective_preserve_manual_positions,
1292
+ fit: fit
1293
+ ).each do |name, position|
1294
+ state = @states[name]
1295
+ next if effective_preserve_manual_positions && manual_position?(name) && resolve_fit(fit) == :none
1296
+
1297
+ @states[name] = State.new(
1298
+ id: state.id,
1299
+ x: position[:x],
1300
+ y: position[:y],
1301
+ label: state.label,
1302
+ style: state.style,
1303
+ metadata: state.metadata,
1304
+ shape: state.shape,
1305
+ kind: state.kind
1306
+ )
1307
+ end
1308
+ graph_changed!
1309
+ self
1310
+ end
1311
+
1312
+ def layout_linear_positions(auto_states, width, height, direction, state_radius = DEFAULT_STATE_RADIUS,
1313
+ padding = DEFAULT_PADDING, node_spacing = DEFAULT_NODE_SPACING)
1314
+ return {} if auto_states.empty?
1315
+
1316
+ margin = [padding, state_radius + 20].max
1317
+ available_x = [width - (2 * margin), 0].max.to_f
1318
+ available_y = [height - (2 * margin), 0].max.to_f
1319
+ count = auto_states.size
1320
+ horizontal_step = count > 1 ? [available_x / (count - 1), node_spacing].max : 0
1321
+ vertical_step = count > 1 ? [available_y / (count - 1), node_spacing].max : 0
1322
+
1323
+ positions = {}
1324
+ auto_states.each_with_index do |name, index|
1325
+ positions[name] = layout_linear_position(
1326
+ index,
1327
+ count,
1328
+ width.to_f,
1329
+ height.to_f,
1330
+ margin,
1331
+ horizontal_step,
1332
+ vertical_step,
1333
+ direction
1334
+ )
1335
+ end
1336
+
1337
+ positions
1338
+ end
1339
+
1340
+ def layout_circle_positions(auto_states, width, height, direction, state_radius = DEFAULT_STATE_RADIUS,
1341
+ padding = DEFAULT_PADDING)
1342
+ return {} if auto_states.empty?
1343
+
1344
+ count = auto_states.size
1345
+ ordered = (direction == :rl || direction == :bt) ? auto_states.reverse : auto_states
1346
+ center_x = width / 2.0
1347
+ center_y = height / 2.0
1348
+ margin = [padding, state_radius + 20].max
1349
+ max_radius = [width, height].min / 2.0 - margin - state_radius
1350
+ minimum_radius = if count > 1
1351
+ (state_radius * 2.0) / (2.0 * Math.sin(Math::PI / count))
1352
+ else
1353
+ 0.0
1354
+ end
1355
+ radius = [max_radius, minimum_radius, state_radius + 20].max
1356
+ angle_start = case direction
1357
+ when :tb then 0.0
1358
+ when :bt then Math::PI
1359
+ else
1360
+ -Math::PI / 2.0
1361
+ end
1362
+ angle_step = (2 * Math::PI) / count
1363
+
1364
+ positions = {}
1365
+ ordered.each_with_index do |name, index|
1366
+ angle = angle_start + (angle_step * index)
1367
+ positions[name] = {
1368
+ x: center_x + (radius * Math.cos(angle)),
1369
+ y: center_y + (radius * Math.sin(angle))
1370
+ }
1371
+ end
1372
+
1373
+ positions
1374
+ end
1375
+
1376
+ def layout_grid_positions(auto_states, width, height, direction, state_radius = DEFAULT_STATE_RADIUS,
1377
+ padding = DEFAULT_PADDING, node_spacing = DEFAULT_NODE_SPACING)
1378
+ return {} if auto_states.empty?
1379
+
1380
+ count = auto_states.size
1381
+ columns = Math.sqrt(count).ceil
1382
+ rows = [(count.to_f / columns).ceil, 1].max.to_i
1383
+
1384
+ margin = [padding, state_radius + 20].max
1385
+ available_x = [width - (2 * margin), 0].max.to_f
1386
+ available_y = [height - (2 * margin), 0].max.to_f
1387
+ horizontal_step = columns > 1 ? [available_x / (columns - 1), node_spacing].max : 0
1388
+ vertical_step = rows > 1 ? [available_y / (rows - 1), node_spacing].max : 0
1389
+
1390
+ positions = {}
1391
+ auto_states.each_with_index do |name, index|
1392
+ if direction == :tb || direction == :bt
1393
+ row = index % rows
1394
+ column = index / rows
1395
+ y = if direction == :tb
1396
+ margin + (row * vertical_step)
1397
+ else
1398
+ height - margin - (row * vertical_step)
1399
+ end
1400
+ else
1401
+ row = index / columns
1402
+ column = index % columns
1403
+ y = margin + (row * vertical_step)
1404
+ end
1405
+
1406
+ x = if direction == :rl
1407
+ width - margin - (column * horizontal_step)
1408
+ else
1409
+ margin + (column * horizontal_step)
1410
+ end
1411
+
1412
+ positions[name] = { x: x, y: y }
1413
+ end
1414
+
1415
+ positions
1416
+ end
1417
+
1418
+ def layout_layered_positions(auto_states, width, height, direction, state_radius = DEFAULT_STATE_RADIUS,
1419
+ padding = DEFAULT_PADDING, node_spacing = DEFAULT_NODE_SPACING,
1420
+ rank_spacing = DEFAULT_RANK_SPACING, final_position: DEFAULT_FINAL_POSITION)
1421
+ return {} if auto_states.empty?
1422
+
1423
+ layer_groups = layout_layered_groups(auto_states, final_position: final_position)
1424
+ return layout_linear_positions(auto_states, width, height, direction, state_radius, padding, node_spacing) if layer_groups.empty?
1425
+
1426
+ margin = [padding, state_radius + 20].max
1427
+ available_x = [width - (2 * margin), 0].max.to_f
1428
+ available_y = [height - (2 * margin), 0].max.to_f
1429
+ layers = layer_groups.keys
1430
+ layer_count = layers.size
1431
+
1432
+ positions = {}
1433
+ layers = layers.sort_by do |depth|
1434
+ depth.to_i
1435
+ end
1436
+ layer_groups = crossing_reduced_layer_groups(layer_groups, layers)
1437
+
1438
+ layers.each_with_index do |layer, layer_index|
1439
+ states = layer_groups[layer] || []
1440
+ state_count = states.size
1441
+ next if state_count.zero?
1442
+
1443
+ if direction == :lr || direction == :rl
1444
+ x = if layer_count > 1
1445
+ margin + (rank_spacing * layer_index)
1446
+ else
1447
+ width / 2.0
1448
+ end
1449
+ x = width - margin - ((rank_spacing * layer_index)) if direction == :rl && layer_count > 1
1450
+ y_step = state_count > 1 ? [available_y / (state_count + 1), node_spacing].max : 0
1451
+
1452
+ states.each_with_index do |name, state_index|
1453
+ y = if state_count > 1
1454
+ margin + ((state_index + 1) * y_step)
1455
+ else
1456
+ height / 2.0
1457
+ end
1458
+ positions[name] = { x: x, y: y }
1459
+ end
1460
+ else
1461
+ y = if layer_count > 1
1462
+ margin + (rank_spacing * layer_index)
1463
+ else
1464
+ height / 2.0
1465
+ end
1466
+ y = height - margin - (rank_spacing * layer_index) if direction == :bt && layer_count > 1
1467
+ x_step = state_count > 1 ? [available_x / (state_count + 1), node_spacing].max : 0
1468
+
1469
+ states.each_with_index do |name, state_index|
1470
+ x = if state_count > 1
1471
+ margin + ((state_index + 1) * x_step)
1472
+ else
1473
+ width / 2.0
1474
+ end
1475
+ positions[name] = { x: x, y: y }
1476
+ end
1477
+ end
1478
+ end
1479
+
1480
+ positions
1481
+ end
1482
+
1483
+ def crossing_reduced_layer_groups(layer_groups, layers)
1484
+ ordered = {}
1485
+ previous_order = nil
1486
+
1487
+ layers.each do |layer|
1488
+ states = layer_groups[layer] || []
1489
+ ordered[layer] = if previous_order
1490
+ order_layer_by_neighbor_barycenter(states, previous_order, incoming: true)
1491
+ else
1492
+ states
1493
+ end
1494
+ previous_order = ordered[layer]
1495
+ end
1496
+
1497
+ next_order = nil
1498
+ layers.reverse_each do |layer|
1499
+ states = ordered[layer] || []
1500
+ ordered[layer] = order_layer_by_neighbor_barycenter(states, next_order, incoming: false) if next_order
1501
+ next_order = ordered[layer]
1502
+ end
1503
+
1504
+ ordered
1505
+ end
1506
+
1507
+ def order_layer_by_neighbor_barycenter(states, adjacent_order, incoming:)
1508
+ adjacent_index = adjacent_order.each_with_index.to_h
1509
+ original_index = states.each_with_index.to_h
1510
+
1511
+ states.sort_by do |name|
1512
+ neighbor_positions = layer_neighbor_positions(name, adjacent_index, incoming: incoming)
1513
+ if neighbor_positions.empty?
1514
+ [1, original_index[name], 0.0]
1515
+ else
1516
+ average = neighbor_positions.sum.to_f / neighbor_positions.size
1517
+ [0, average, original_index[name]]
1518
+ end
1519
+ end
1520
+ end
1521
+
1522
+ def layer_neighbor_positions(name, adjacent_index, incoming:)
1523
+ ensure_analysis_index!
1524
+ transitions = incoming ? @incoming_by_state[name] : @outgoing_by_state[name]
1525
+ transitions.filter_map do |transition|
1526
+ neighbor = incoming ? transition.from : transition.to
1527
+ adjacent_index[neighbor] if neighbor && adjacent_index.key?(neighbor)
1528
+ end
1529
+ end
1530
+
1531
+ def layout_layered_groups(auto_states, final_position: DEFAULT_FINAL_POSITION)
1532
+ return {} if auto_states.empty?
1533
+ distances = layered_distances
1534
+ return {} if distances.empty?
1535
+
1536
+ resolved_final_position = resolve_final_position(final_position)
1537
+ groups = Hash.new { |hash, key| hash[key] = [] }
1538
+ auto_states.each do |name|
1539
+ next unless distances.key?(name)
1540
+
1541
+ groups[distances[name]] << name
1542
+ end
1543
+
1544
+ unreachable_states = auto_states.reject { |name| distances.key?(name) }
1545
+ if unreachable_states.any?
1546
+ max_depth = distances.values.max || 0
1547
+ weak_components(unreachable_states).each_with_index do |component, index|
1548
+ groups[max_depth + 1 + index].concat(component)
1549
+ end
1550
+ end
1551
+
1552
+ return groups unless resolved_final_position == :end
1553
+
1554
+ final_states = auto_states.select { |name| @final_states.include?(name) }
1555
+ return groups if final_states.empty?
1556
+
1557
+ groups.each_value do |states|
1558
+ states.reject! { |name| @final_states.include?(name) }
1559
+ end
1560
+
1561
+ final_layer = (groups.keys.max || 0) + 1
1562
+ groups[final_layer] = []
1563
+ auto_states.each do |name|
1564
+ groups[final_layer] << name if @final_states.include?(name)
1565
+ end
1566
+
1567
+ groups
1568
+ end
1569
+
1570
+ def weak_components(states)
1571
+ return [] if states.empty?
1572
+
1573
+ ensure_analysis_index!
1574
+ remaining = states.to_h { |state| [state, true] }
1575
+
1576
+ components = []
1577
+ while (seed = remaining.keys.first)
1578
+ stack = [seed]
1579
+ component = []
1580
+
1581
+ until stack.empty?
1582
+ state = stack.pop
1583
+ next unless remaining.delete(state)
1584
+
1585
+ component << state
1586
+ @undirected_neighbors[state].each do |next_state|
1587
+ next unless remaining.key?(next_state)
1588
+
1589
+ stack << next_state
1590
+ end
1591
+ end
1592
+
1593
+ components << component
1594
+ end
1595
+
1596
+ components
1597
+ end
1598
+
1599
+ def layered_distances
1600
+ return {} unless @initial_state && @states[@initial_state]
1601
+
1602
+ ensure_analysis_index!
1603
+
1604
+ distances = {}
1605
+ queue = [@initial_state]
1606
+ distances[@initial_state] = 0
1607
+
1608
+ head = 0
1609
+ while head < queue.length
1610
+ current = queue[head]
1611
+ head += 1
1612
+ @outgoing_by_state[current].each do |transition|
1613
+ next_state = transition.to
1614
+ next if distances.key?(next_state)
1615
+
1616
+ distances[next_state] = distances[current] + 1
1617
+ queue << next_state
1618
+ end
1619
+ end
1620
+
1621
+ distances
1622
+ end
1623
+
1624
+ def layout_force_positions(auto_states, width, height, direction, state_radius = DEFAULT_STATE_RADIUS,
1625
+ padding = DEFAULT_PADDING, node_spacing = DEFAULT_NODE_SPACING,
1626
+ force_iterations = DEFAULT_FORCE_ITERATIONS, layout_seed = nil, fixed_positions: {})
1627
+ return {} if auto_states.empty?
1628
+
1629
+ iterations = [force_iterations.to_i, 0].max
1630
+ return {} if width <= 0 || height <= 0
1631
+
1632
+ positions = {}
1633
+ count = auto_states.size
1634
+ center_x = width / 2.0
1635
+ center_y = height / 2.0
1636
+ radius = [width, height].min / 4.0
1637
+ radius = [radius, node_spacing].min if radius > 0
1638
+
1639
+ auto_states.each_with_index do |name, index|
1640
+ if count == 1
1641
+ x = center_x
1642
+ y = center_y
1643
+ else
1644
+ offset_ratio = count > 1 ? (index.to_f / (count - 1)) : 0.5
1645
+
1646
+ case direction
1647
+ when :lr
1648
+ x = padding + (offset_ratio * (width - (2 * padding)))
1649
+ y = center_y
1650
+ when :rl
1651
+ x = width - padding - (offset_ratio * (width - (2 * padding)))
1652
+ y = center_y
1653
+ when :tb
1654
+ x = center_x
1655
+ y = padding + (offset_ratio * (height - (2 * padding)))
1656
+ when :bt
1657
+ x = center_x
1658
+ y = height - padding - (offset_ratio * (height - (2 * padding)))
1659
+ else
1660
+ angle = (2 * Math::PI * index) / count
1661
+ x = center_x + (Math.cos(angle) * radius)
1662
+ y = center_y + (Math.sin(angle) * radius)
1663
+ end
1664
+ end
1665
+
1666
+ positions[name] = { x: x.to_f, y: y.to_f }
1667
+ end
1668
+
1669
+ return positions if iterations.zero?
1670
+
1671
+ rng = layout_seed ? Random.new(layout_seed) : nil
1672
+
1673
+ if rng
1674
+ positions.each_value do |position|
1675
+ position[:x] += (rng.rand - 0.5) * 10
1676
+ position[:y] += (rng.rand - 0.5) * 10
1677
+ end
1678
+ end
1679
+
1680
+ manual_positions = fixed_positions
1681
+
1682
+ k = [node_spacing, 1.0].max
1683
+ attraction_coeff = 0.01
1684
+ repulsion_coeff = (k * k)
1685
+ max_displacement = [width, height].min * 0.05
1686
+
1687
+ iterations.times do |step|
1688
+ forces = auto_states.to_h do |name|
1689
+ [name, { x: 0.0, y: 0.0 }]
1690
+ end
1691
+
1692
+ if positions.size + manual_positions.size >= FORCE_TREE_THRESHOLD
1693
+ force_tree = Layout::ForceTree.new(manual_positions.merge(positions))
1694
+ positions.each do |name, current|
1695
+ force_x, force_y = force_tree.force_on(name, current, repulsion_coeff) do |left, right|
1696
+ deterministic_separation_delta(left, right)
1697
+ end
1698
+ forces[name][:x] += force_x
1699
+ forces[name][:y] += force_y
1700
+ end
1701
+ else
1702
+ accumulate_exact_repulsion!(forces, positions, manual_positions, repulsion_coeff)
1703
+ end
1704
+
1705
+ @transitions.each do |transition|
1706
+ from = transition[:from]
1707
+ to = transition[:to]
1708
+
1709
+ from_point = positions[from] || manual_positions[from]
1710
+ to_point = positions[to] || manual_positions[to]
1711
+ next unless from_point && to_point
1712
+
1713
+ delta_x = to_point[:x] - from_point[:x]
1714
+ delta_y = to_point[:y] - from_point[:y]
1715
+ distance = Math.sqrt((delta_x * delta_x) + (delta_y * delta_y))
1716
+ distance = 1.0 if distance <= 0.0
1717
+
1718
+ force = (distance * distance) / k
1719
+ nx = delta_x / distance
1720
+ ny = delta_y / distance
1721
+
1722
+ if positions.key?(from)
1723
+ forces[from][:x] += nx * force * attraction_coeff
1724
+ forces[from][:y] += ny * force * attraction_coeff
1725
+ end
1726
+
1727
+ if positions.key?(to)
1728
+ forces[to][:x] -= nx * force * attraction_coeff
1729
+ forces[to][:y] -= ny * force * attraction_coeff
1730
+ end
1731
+ end
1732
+
1733
+ damping = 1.0 - (step.to_f / (iterations + 1).to_f)
1734
+ max_move = max_displacement * damping
1735
+ largest_movement = 0.0
1736
+
1737
+ positions.each_key do |name|
1738
+ current = positions[name]
1739
+ force = forces[name]
1740
+ next unless current && force
1741
+
1742
+ next_x = current[:x] + force[:x].clamp(-max_move, max_move)
1743
+ next_y = current[:y] + force[:y].clamp(-max_move, max_move)
1744
+
1745
+ boundary_margin = padding + state_radius
1746
+ next_x = [[next_x, boundary_margin].max, width - boundary_margin].min if width >= boundary_margin * 2
1747
+ next_y = [[next_y, boundary_margin].max, height - boundary_margin].min if height >= boundary_margin * 2
1748
+
1749
+ movement = Math.hypot(next_x - current[:x], next_y - current[:y])
1750
+ largest_movement = movement if movement > largest_movement
1751
+
1752
+ current[:x] = next_x
1753
+ current[:y] = next_y
1754
+ end
1755
+ break if largest_movement < 0.01
1756
+ end
1757
+
1758
+ positions
1759
+ end
1760
+
1761
+ def accumulate_exact_repulsion!(forces, positions, manual_positions, coefficient)
1762
+ positions.to_a.combination(2) do |(name_a, a), (name_b, b)|
1763
+ delta_x = a[:x] - b[:x]
1764
+ delta_y = a[:y] - b[:y]
1765
+ if delta_x.zero? && delta_y.zero?
1766
+ delta_x, delta_y = deterministic_separation_delta(name_a, name_b)
1767
+ end
1768
+ force_x, force_y = repulsion_vector(delta_x, delta_y, coefficient)
1769
+ forces[name_a][:x] += force_x
1770
+ forces[name_a][:y] += force_y
1771
+ forces[name_b][:x] -= force_x
1772
+ forces[name_b][:y] -= force_y
1773
+ end
1774
+
1775
+ manual_positions.each do |fixed_name, fixed|
1776
+ positions.each do |name, current|
1777
+ delta_x = current[:x] - fixed[:x].to_f
1778
+ delta_y = current[:y] - fixed[:y].to_f
1779
+ if delta_x.zero? && delta_y.zero?
1780
+ delta_x, delta_y = deterministic_separation_delta(name, fixed_name)
1781
+ end
1782
+ force_x, force_y = repulsion_vector(delta_x, delta_y, coefficient)
1783
+ forces[name][:x] += force_x
1784
+ forces[name][:y] += force_y
1785
+ end
1786
+ end
1787
+ end
1788
+
1789
+ def repulsion_vector(delta_x, delta_y, coefficient)
1790
+ distance = Math.hypot(delta_x, delta_y)
1791
+ return [0.0, 0.0] unless distance.positive?
1792
+
1793
+ force = coefficient / distance
1794
+ [(delta_x / distance) * force, (delta_y / distance) * force]
1795
+ end
1796
+ private :accumulate_exact_repulsion!, :repulsion_vector
1797
+
1798
+ def deterministic_separation_delta(left, right)
1799
+ seed = "#{left.class.name}:#{left.inspect}|#{right.class.name}:#{right.inspect}".each_byte.reduce(2_166_136_261) do |hash, byte|
1800
+ ((hash ^ byte) * 16_777_619) & 0xffffffff
1801
+ end
1802
+ angle = (seed % 360) * Math::PI / 180.0
1803
+ [Math.cos(angle) * 0.01, Math.sin(angle) * 0.01]
1804
+ end
1805
+ private :deterministic_separation_delta
1806
+
1807
+ def layout_graphviz_positions(auto_states, width, height, direction, state_radius = DEFAULT_STATE_RADIUS,
1808
+ padding = DEFAULT_PADDING, command: DEFAULT_GRAPHVIZ_COMMAND)
1809
+ return {} if auto_states.empty?
1810
+
1811
+ state_ids = graphviz_layout_state_ids(auto_states)
1812
+ stdout, stderr, status = ProcessRunner.capture3(
1813
+ *graphviz_command_args(command),
1814
+ '-Tplain',
1815
+ stdin_data: graphviz_layout_dot(auto_states, direction, state_ids)
1816
+ )
1817
+
1818
+ unless status.success?
1819
+ message = stderr.to_s.strip
1820
+ message = 'dot exited without a diagnostic' if message.empty?
1821
+ raise LayoutError, "Graphviz layout failed: #{message}"
1822
+ end
1823
+
1824
+ normalize_graphviz_positions(
1825
+ parse_graphviz_plain_positions(stdout, auto_states, state_ids),
1826
+ width,
1827
+ height,
1828
+ state_radius,
1829
+ padding
1830
+ )
1831
+ rescue Errno::ENOENT
1832
+ raise LayoutError, "Graphviz layout requires the `#{Array(command).join(' ')}` command"
1833
+ rescue ProcessRunner::Error => e
1834
+ raise LayoutError, "Graphviz layout failed: #{e.message}"
1835
+ rescue ArgumentError => e
1836
+ raise LayoutError, "Graphviz layout failed: #{e.message}"
1837
+ end
1838
+
1839
+ def graphviz_layout_state_ids(auto_states)
1840
+ allocator = IdentifierAllocator.new
1841
+ auto_states.to_h do |name|
1842
+ preferred = name.to_s if name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
1843
+ [name, allocator.allocate([:state, name], preferred: preferred, prefix: 'state')]
1844
+ end
1845
+ end
1846
+
1847
+ def graphviz_layout_dot(auto_states, direction, state_ids)
1848
+ included = auto_states.to_h { |name| [name, true] }
1849
+ lines = [
1850
+ 'digraph graphomaton_layout {',
1851
+ " rankdir=#{graphviz_rankdir(direction)};",
1852
+ ' node [shape=circle];'
1853
+ ]
1854
+
1855
+ auto_states.each do |name|
1856
+ lines << " \"#{state_ids.fetch(name)}\";"
1857
+ end
1858
+
1859
+ @transitions.each do |transition|
1860
+ from = transition[:from]
1861
+ to = transition[:to]
1862
+ next unless included[from] && included[to]
1863
+
1864
+ lines << " \"#{state_ids.fetch(from)}\" -> \"#{state_ids.fetch(to)}\";"
1865
+ end
1866
+
1867
+ lines << '}'
1868
+ lines.join("\n")
1869
+ end
1870
+
1871
+ def graphviz_command_args(command)
1872
+ args = command.is_a?(Array) ? command.map(&:to_s) : Shellwords.split(command.to_s)
1873
+ raise ArgumentError, 'Graphviz command cannot be empty' if args.empty?
1874
+
1875
+ args
1876
+ end
1877
+
1878
+ def graphviz_rankdir(direction)
1879
+ {
1880
+ lr: 'LR',
1881
+ rl: 'RL',
1882
+ tb: 'TB',
1883
+ bt: 'BT'
1884
+ }.fetch(direction)
1885
+ end
1886
+
1887
+ def parse_graphviz_plain_positions(output, expected_states, state_ids)
1888
+ positions = {}
1889
+
1890
+ output.each_line do |line|
1891
+ tokens = Shellwords.split(line)
1892
+ next unless tokens.first == 'node' && tokens.size >= 4
1893
+
1894
+ positions[tokens[1]] = {
1895
+ x: Float(tokens[2]),
1896
+ y: Float(tokens[3])
1897
+ }
1898
+ rescue ArgumentError
1899
+ next
1900
+ end
1901
+
1902
+ missing = expected_states.reject { |name| positions.key?(state_ids.fetch(name)) }
1903
+ unless missing.empty?
1904
+ raise ArgumentError, "Graphviz layout did not return positions for: #{missing.join(', ')}"
1905
+ end
1906
+
1907
+ expected_states.to_h { |name| [name, positions.fetch(state_ids.fetch(name))] }
1908
+ end
1909
+
1910
+ def normalize_graphviz_positions(raw_positions, width, height, state_radius, padding)
1911
+ return {} if raw_positions.empty?
1912
+
1913
+ canvas_width = width.to_f
1914
+ canvas_height = height.to_f
1915
+ margin = [padding.to_f, state_radius.to_f + 20].max
1916
+ available_x = [canvas_width - (2 * margin), 0].max
1917
+ available_y = [canvas_height - (2 * margin), 0].max
1918
+ xs = raw_positions.values.map { |position| position[:x].to_f }
1919
+ ys = raw_positions.values.map { |position| position[:y].to_f }
1920
+ min_x, max_x = xs.minmax
1921
+ min_y, max_y = ys.minmax
1922
+ span_x = max_x - min_x
1923
+ span_y = max_y - min_y
1924
+
1925
+ if span_x <= 0.0 && span_y <= 0.0
1926
+ return raw_positions.to_h do |name, _position|
1927
+ [name, { x: canvas_width / 2.0, y: canvas_height / 2.0 }]
1928
+ end
1929
+ end
1930
+
1931
+ scale_candidates = []
1932
+ scale_candidates << (available_x / span_x) if span_x.positive?
1933
+ scale_candidates << (available_y / span_y) if span_y.positive?
1934
+ scale = scale_candidates.min || 1.0
1935
+ graph_width = span_x * scale
1936
+ graph_height = span_y * scale
1937
+ offset_x = margin + ((available_x - graph_width) / 2.0)
1938
+ offset_y = margin + ((available_y - graph_height) / 2.0)
1939
+
1940
+ raw_positions.to_h do |name, position|
1941
+ x = if span_x.positive?
1942
+ offset_x + ((position[:x].to_f - min_x) * scale)
1943
+ else
1944
+ canvas_width / 2.0
1945
+ end
1946
+ y = if span_y.positive?
1947
+ offset_y + ((max_y - position[:y].to_f) * scale)
1948
+ else
1949
+ canvas_height / 2.0
1950
+ end
1951
+
1952
+ [name, { x: x, y: y }]
1953
+ end
1954
+ end
1955
+
1956
+ def count_parallel_transitions(from, to)
1957
+ ensure_analysis_index!
1958
+ @transitions_by_undirected_pair.fetch(Set[from, to].freeze, EMPTY_TRANSITIONS).size
1959
+ end
1960
+
1961
+ def get_transition_index(from, to, label)
1962
+ ensure_analysis_index!
1963
+ transitions = @transitions_by_undirected_pair.fetch(Set[from, to].freeze, EMPTY_TRANSITIONS)
1964
+ transitions.index { |transition| transition.from == from && transition.to == to && transition.label == label } || transitions.size
1965
+ end
1966
+
1967
+ def outgoing_by_state
1968
+ ensure_analysis_index!
1969
+ @outgoing_by_state.transform_values { |transitions| transitions.map(&:to_h).freeze }.freeze
1970
+ end
1971
+
1972
+ def incoming_by_state
1973
+ ensure_analysis_index!
1974
+ @incoming_by_state.transform_values { |transitions| transitions.map(&:to_h).freeze }.freeze
1975
+ end
1976
+
1977
+ def transitions_by_pair
1978
+ ensure_analysis_index!
1979
+ @transitions_by_directed_pair.transform_values { |transitions| transitions.map(&:to_h).freeze }.freeze
1980
+ end
1981
+
1982
+ def to_h
1983
+ output = {
1984
+ version: 1,
1985
+ states: @states.values.map do |state|
1986
+ serialized = { id: state.id }
1987
+ serialized[:x] = state.x unless state.x.nil?
1988
+ serialized[:y] = state.y unless state.y.nil?
1989
+ serialized[:label] = state.label unless state.label.nil?
1990
+ serialized[:style] = state.style unless state.style.nil?
1991
+ serialized[:metadata] = state.metadata unless state.metadata.nil?
1992
+ serialized[:shape] = state.shape unless state.shape.nil?
1993
+ serialized[:kind] = state.kind unless state.kind.nil?
1994
+ serialized
1995
+ end,
1996
+ transitions: @transitions.map do |transition|
1997
+ serialized = transition.to_h
1998
+ serialized[:label] = transition.label.to_h if transition.label.is_a?(Label)
1999
+ serialized
2000
+ end
2001
+ }
2002
+ output[:initial] = @initial_state unless @initial_state.nil?
2003
+ output[:final] = @final_states unless @final_states.empty?
2004
+ immutable_snapshot(output)
2005
+ end
2006
+
2007
+ def to_json(*arguments)
2008
+ JSON.generate(to_h, *arguments)
2009
+ end
2010
+
2011
+ def to_yaml(**options)
2012
+ to_h.to_yaml(**options)
2013
+ end
2014
+
2015
+ def ==(other)
2016
+ other.is_a?(Graphomaton) && to_h == other.to_h
2017
+ end
2018
+
2019
+ def write(io, format: :svg, width: 800, height: 600, **options)
2020
+ resolved = resolve_format(format)
2021
+ output = render(format: resolved, width: width, height: height, **options)
2022
+ io.binmode if io.respond_to?(:binmode) && self.class::EXPORTERS.fetch(resolved).binary
2023
+ io.write(output)
2024
+ end
2025
+
2026
+ def semantic_diagnostics(format, **options)
2027
+ resolved = resolve_format(format)
2028
+ capabilities = self.class.exporter_capabilities(resolved).dup
2029
+ capabilities << :state_tooltip if options[:notes] && %i[html mermaid plantuml].include?(resolved)
2030
+ ExporterCapabilities.losses_for(self, capabilities).map do |feature|
2031
+ Diagnostic.new(
2032
+ code: 'unsupported-export-feature',
2033
+ severity: :warning,
2034
+ path: ['export', resolved.to_s],
2035
+ message: "#{resolved} output does not preserve #{feature}",
2036
+ hint: 'Choose SVG or remove the unsupported feature.'
2037
+ )
2038
+ end.freeze
2039
+ end
2040
+
2041
+ def render(format: :svg, width: 800, height: 600, strict_semantics: false, **options)
2042
+ resolved_format = resolve_format(format)
2043
+ losses = semantic_diagnostics(resolved_format, **options)
2044
+ if strict_semantics && losses.any?
2045
+ raise ExportError, losses.map(&:message).join("\n")
2046
+ end
2047
+
2048
+ case resolved_format
2049
+ when :svg
2050
+ to_svg(width, height, **options)
2051
+ when :png
2052
+ to_png(width, height, **options)
2053
+ when :pdf
2054
+ to_pdf(width, height, **options)
2055
+ when :webp
2056
+ to_webp(width, height, **options)
2057
+ when :html
2058
+ to_html(**options)
2059
+ when :mermaid
2060
+ to_mermaid(**options)
2061
+ when :dot
2062
+ to_dot(**options)
2063
+ when :plantuml
2064
+ to_plantuml(**options)
2065
+ else
2066
+ exporter = self.class::EXPORTERS.fetch(resolved_format).exporter.new(self)
2067
+ exporter.export(width, height, **options)
2068
+ end
2069
+ end
2070
+
2071
+ def render_with(options)
2072
+ raise ArgumentError, 'options must be a Graphomaton::RenderOptions' unless options.is_a?(RenderOptions)
2073
+
2074
+ render(format: options.format, width: options.width, height: options.height, **options.options)
2075
+ end
2076
+
2077
+ def render_result(format: :svg, width: 800, height: 600, strict_semantics: false, **options)
2078
+ resolved = resolve_format(format)
2079
+ losses = semantic_diagnostics(resolved, **options)
2080
+ if strict_semantics && losses.any?
2081
+ raise ExportError, losses.map(&:message).join("\n")
2082
+ end
2083
+
2084
+ exporter = case resolved
2085
+ when :svg then Exporters::Svg
2086
+ when :png then Exporters::Png
2087
+ when :pdf then Exporters::Pdf
2088
+ when :webp then Exporters::Webp
2089
+ end
2090
+ if exporter
2091
+ result = exporter.new(self).export_result(width, height, **options)
2092
+ return result if losses.empty?
2093
+
2094
+ return RenderResult.new(
2095
+ output: result.output,
2096
+ diagnostics: (losses + result.diagnostics).freeze,
2097
+ bounds: result.bounds,
2098
+ layout: result.layout
2099
+ )
2100
+ end
2101
+
2102
+ output = render(
2103
+ format: resolved,
2104
+ width: width,
2105
+ height: height,
2106
+ strict_semantics: strict_semantics,
2107
+ **options
2108
+ )
2109
+ RenderResult.new(
2110
+ output: output,
2111
+ diagnostics: losses,
2112
+ bounds: nil,
2113
+ layout: nil
2114
+ )
2115
+ end
2116
+
2117
+ def save(filename, format: nil, width: 800, height: 600, **options)
2118
+ resolved_format = resolve_format(format || File.extname(filename).delete_prefix('.'))
2119
+ output = render(format: resolved_format, width: width, height: height, **options)
2120
+ AtomicFile.write(filename, output, binary: self.class::EXPORTERS.fetch(resolved_format).binary)
2121
+ end
2122
+
2123
+ def to_svg(width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2124
+ layout: :linear, direction: :lr, responsive: false, state_radius: DEFAULT_STATE_RADIUS,
2125
+ auto_state_radius: Exporters::Svg::DEFAULT_AUTO_STATE_RADIUS,
2126
+ min_state_radius: Exporters::Svg::DEFAULT_MIN_STATE_RADIUS,
2127
+ max_state_radius: Exporters::Svg::DEFAULT_MAX_STATE_RADIUS,
2128
+ state_shape: Exporters::Svg::DEFAULT_STATE_SHAPE,
2129
+ state_stroke_width: Exporters::Svg::DEFAULT_STATE_STROKE_WIDTH,
2130
+ transition_stroke_width: Exporters::Svg::DEFAULT_TRANSITION_STROKE_WIDTH,
2131
+ padding: DEFAULT_PADDING, node_spacing: DEFAULT_NODE_SPACING, rank_spacing: DEFAULT_RANK_SPACING,
2132
+ force_iterations: DEFAULT_FORCE_ITERATIONS, layout_seed: nil, auto_size: false,
2133
+ graphviz_command: DEFAULT_GRAPHVIZ_COMMAND,
2134
+ auto_density_spacing: Exporters::Svg::DEFAULT_AUTO_DENSITY_SPACING,
2135
+ arrow_size: Exporters::Svg::DEFAULT_ARROW_SIZE,
2136
+ arrow_shape: Exporters::Svg::DEFAULT_ARROW_SHAPE,
2137
+ initial_arrow_length: Exporters::Svg::DEFAULT_INITIAL_ARROW_LENGTH,
2138
+ initial_arrow_label: Exporters::Svg::DEFAULT_INITIAL_ARROW_LABEL,
2139
+ final_arrow_length: Exporters::Svg::DEFAULT_FINAL_ARROW_LENGTH,
2140
+ final_arrow_label: Exporters::Svg::DEFAULT_FINAL_ARROW_LABEL,
2141
+ initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
2142
+ merge_parallel_transitions: true, wrap: Exporters::Svg::DEFAULT_WRAP,
2143
+ max_transition_label_width: Exporters::Svg::DEFAULT_MAX_LABEL_WIDTH, state_wrap: false,
2144
+ max_state_label_width: Exporters::Svg::DEFAULT_MAX_STATE_LABEL_WIDTH,
2145
+ sort_labels: Exporters::Svg::DEFAULT_SORT_LABELS,
2146
+ label_tooltips: Exporters::Svg::DEFAULT_LABEL_TOOLTIPS,
2147
+ html_tooltips: Exporters::Svg::DEFAULT_HTML_TOOLTIPS,
2148
+ font_family: Exporters::Svg::DEFAULT_FONT_FAMILY,
2149
+ state_font_weight: Exporters::Svg::DEFAULT_STATE_FONT_WEIGHT,
2150
+ transition_font_weight: Exporters::Svg::DEFAULT_TRANSITION_FONT_WEIGHT,
2151
+ label_background: Exporters::Svg::DEFAULT_LABEL_BACKGROUND,
2152
+ label_border: Exporters::Svg::DEFAULT_LABEL_BORDER,
2153
+ label_padding: Exporters::Svg::DEFAULT_LABEL_PADDING,
2154
+ label_radius: Exporters::Svg::DEFAULT_LABEL_RADIUS,
2155
+ rotate_labels: Exporters::Svg::DEFAULT_ROTATE_LABELS,
2156
+ highlight_unreachable: false,
2157
+ highlight_dead_states: Exporters::Svg::DEFAULT_HIGHLIGHT_DEAD_STATES,
2158
+ highlight_initial_state: Exporters::Svg::DEFAULT_HIGHLIGHT_INITIAL_STATE,
2159
+ highlight_final_states: Exporters::Svg::DEFAULT_HIGHLIGHT_FINAL_STATES,
2160
+ highlight_transitions: Exporters::Svg::DEFAULT_HIGHLIGHT_TRANSITIONS,
2161
+ unreachable_zone: Exporters::Svg::DEFAULT_UNREACHABLE_ZONE,
2162
+ xml_declaration: Exporters::Svg::DEFAULT_XML_DECLARATION,
2163
+ css_variables: Exporters::Svg::DEFAULT_CSS_VARIABLES,
2164
+ embed_styles: Exporters::Svg::DEFAULT_EMBED_STYLES,
2165
+ pretty: Exporters::Svg::DEFAULT_PRETTY,
2166
+ minify: Exporters::Svg::DEFAULT_MINIFY,
2167
+ state_effect: Exporters::Svg::DEFAULT_STATE_EFFECT,
2168
+ loop_position: Exporters::Svg::DEFAULT_LOOP_POSITION,
2169
+ edge_style: Exporters::Svg::DEFAULT_EDGE_STYLE,
2170
+ show_final_arrows: Exporters::Svg::DEFAULT_SHOW_FINAL_ARROWS,
2171
+ scc_groups: Exporters::Svg::DEFAULT_SCC_GROUPS,
2172
+ fold_groups: Exporters::Svg::DEFAULT_FOLD_GROUPS,
2173
+ preserve_manual_positions: DEFAULT_PRESERVE_MANUAL_POSITIONS,
2174
+ fit: DEFAULT_FIT,
2175
+ title: nil, description: nil, svg_id: nil)
2176
+ Exporters::Svg.new(self).export(
2177
+ width,
2178
+ height,
2179
+ theme: theme,
2180
+ layout: layout,
2181
+ direction: direction,
2182
+ responsive: responsive,
2183
+ state_radius: state_radius,
2184
+ auto_state_radius: auto_state_radius,
2185
+ min_state_radius: min_state_radius,
2186
+ max_state_radius: max_state_radius,
2187
+ state_shape: state_shape,
2188
+ state_stroke_width: state_stroke_width,
2189
+ transition_stroke_width: transition_stroke_width,
2190
+ padding: padding,
2191
+ node_spacing: node_spacing,
2192
+ rank_spacing: rank_spacing,
2193
+ force_iterations: force_iterations,
2194
+ layout_seed: layout_seed,
2195
+ graphviz_command: graphviz_command,
2196
+ auto_size: auto_size,
2197
+ auto_density_spacing: auto_density_spacing,
2198
+ arrow_size: arrow_size,
2199
+ arrow_shape: arrow_shape,
2200
+ initial_arrow_length: initial_arrow_length,
2201
+ initial_arrow_label: initial_arrow_label,
2202
+ final_arrow_length: final_arrow_length,
2203
+ final_arrow_label: final_arrow_label,
2204
+ initial_position: initial_position,
2205
+ final_position: final_position,
2206
+ merge_parallel_transitions: merge_parallel_transitions,
2207
+ label_background: label_background,
2208
+ label_border: label_border,
2209
+ label_padding: label_padding,
2210
+ label_radius: label_radius,
2211
+ rotate_labels: rotate_labels,
2212
+ highlight_unreachable: highlight_unreachable,
2213
+ highlight_dead_states: highlight_dead_states,
2214
+ highlight_initial_state: highlight_initial_state,
2215
+ highlight_final_states: highlight_final_states,
2216
+ highlight_transitions: highlight_transitions,
2217
+ unreachable_zone: unreachable_zone,
2218
+ xml_declaration: xml_declaration,
2219
+ css_variables: css_variables,
2220
+ embed_styles: embed_styles,
2221
+ pretty: pretty,
2222
+ minify: minify,
2223
+ state_effect: state_effect,
2224
+ loop_position: loop_position,
2225
+ edge_style: edge_style,
2226
+ show_final_arrows: show_final_arrows,
2227
+ scc_groups: scc_groups,
2228
+ fold_groups: fold_groups,
2229
+ preserve_manual_positions: preserve_manual_positions,
2230
+ fit: fit,
2231
+ wrap: wrap,
2232
+ max_transition_label_width: max_transition_label_width,
2233
+ state_wrap: state_wrap,
2234
+ max_state_label_width: max_state_label_width,
2235
+ sort_labels: sort_labels,
2236
+ label_tooltips: label_tooltips,
2237
+ html_tooltips: html_tooltips,
2238
+ font_family: font_family,
2239
+ state_font_weight: state_font_weight,
2240
+ transition_font_weight: transition_font_weight,
2241
+ title: title,
2242
+ description: description,
2243
+ svg_id: svg_id
2244
+ )
2245
+ end
2246
+
2247
+ def save_svg(filename, width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2248
+ layout: :linear, direction: :lr, responsive: false, state_radius: DEFAULT_STATE_RADIUS,
2249
+ auto_state_radius: Exporters::Svg::DEFAULT_AUTO_STATE_RADIUS,
2250
+ min_state_radius: Exporters::Svg::DEFAULT_MIN_STATE_RADIUS,
2251
+ max_state_radius: Exporters::Svg::DEFAULT_MAX_STATE_RADIUS,
2252
+ state_shape: Exporters::Svg::DEFAULT_STATE_SHAPE,
2253
+ state_stroke_width: Exporters::Svg::DEFAULT_STATE_STROKE_WIDTH,
2254
+ transition_stroke_width: Exporters::Svg::DEFAULT_TRANSITION_STROKE_WIDTH,
2255
+ padding: DEFAULT_PADDING, node_spacing: DEFAULT_NODE_SPACING, rank_spacing: DEFAULT_RANK_SPACING,
2256
+ force_iterations: DEFAULT_FORCE_ITERATIONS, layout_seed: nil, auto_size: false,
2257
+ graphviz_command: DEFAULT_GRAPHVIZ_COMMAND,
2258
+ auto_density_spacing: Exporters::Svg::DEFAULT_AUTO_DENSITY_SPACING,
2259
+ arrow_size: Exporters::Svg::DEFAULT_ARROW_SIZE,
2260
+ arrow_shape: Exporters::Svg::DEFAULT_ARROW_SHAPE,
2261
+ initial_arrow_length: Exporters::Svg::DEFAULT_INITIAL_ARROW_LENGTH,
2262
+ initial_arrow_label: Exporters::Svg::DEFAULT_INITIAL_ARROW_LABEL,
2263
+ final_arrow_length: Exporters::Svg::DEFAULT_FINAL_ARROW_LENGTH,
2264
+ final_arrow_label: Exporters::Svg::DEFAULT_FINAL_ARROW_LABEL,
2265
+ initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
2266
+ merge_parallel_transitions: true, wrap: Exporters::Svg::DEFAULT_WRAP,
2267
+ max_transition_label_width: Exporters::Svg::DEFAULT_MAX_LABEL_WIDTH, state_wrap: false,
2268
+ max_state_label_width: Exporters::Svg::DEFAULT_MAX_STATE_LABEL_WIDTH,
2269
+ sort_labels: Exporters::Svg::DEFAULT_SORT_LABELS,
2270
+ label_tooltips: Exporters::Svg::DEFAULT_LABEL_TOOLTIPS,
2271
+ html_tooltips: Exporters::Svg::DEFAULT_HTML_TOOLTIPS,
2272
+ font_family: Exporters::Svg::DEFAULT_FONT_FAMILY,
2273
+ state_font_weight: Exporters::Svg::DEFAULT_STATE_FONT_WEIGHT,
2274
+ transition_font_weight: Exporters::Svg::DEFAULT_TRANSITION_FONT_WEIGHT,
2275
+ label_background: Exporters::Svg::DEFAULT_LABEL_BACKGROUND,
2276
+ label_border: Exporters::Svg::DEFAULT_LABEL_BORDER,
2277
+ label_padding: Exporters::Svg::DEFAULT_LABEL_PADDING,
2278
+ label_radius: Exporters::Svg::DEFAULT_LABEL_RADIUS,
2279
+ rotate_labels: Exporters::Svg::DEFAULT_ROTATE_LABELS,
2280
+ highlight_unreachable: false,
2281
+ highlight_dead_states: Exporters::Svg::DEFAULT_HIGHLIGHT_DEAD_STATES,
2282
+ highlight_initial_state: Exporters::Svg::DEFAULT_HIGHLIGHT_INITIAL_STATE,
2283
+ highlight_final_states: Exporters::Svg::DEFAULT_HIGHLIGHT_FINAL_STATES,
2284
+ highlight_transitions: Exporters::Svg::DEFAULT_HIGHLIGHT_TRANSITIONS,
2285
+ unreachable_zone: Exporters::Svg::DEFAULT_UNREACHABLE_ZONE,
2286
+ xml_declaration: Exporters::Svg::DEFAULT_XML_DECLARATION,
2287
+ css_variables: Exporters::Svg::DEFAULT_CSS_VARIABLES,
2288
+ embed_styles: Exporters::Svg::DEFAULT_EMBED_STYLES,
2289
+ pretty: Exporters::Svg::DEFAULT_PRETTY,
2290
+ minify: Exporters::Svg::DEFAULT_MINIFY,
2291
+ state_effect: Exporters::Svg::DEFAULT_STATE_EFFECT,
2292
+ loop_position: Exporters::Svg::DEFAULT_LOOP_POSITION,
2293
+ edge_style: Exporters::Svg::DEFAULT_EDGE_STYLE,
2294
+ show_final_arrows: Exporters::Svg::DEFAULT_SHOW_FINAL_ARROWS,
2295
+ scc_groups: Exporters::Svg::DEFAULT_SCC_GROUPS,
2296
+ fold_groups: Exporters::Svg::DEFAULT_FOLD_GROUPS,
2297
+ preserve_manual_positions: DEFAULT_PRESERVE_MANUAL_POSITIONS,
2298
+ fit: DEFAULT_FIT,
2299
+ title: nil, description: nil, svg_id: nil)
2300
+ AtomicFile.write(
2301
+ filename,
2302
+ to_svg(
2303
+ width,
2304
+ height,
2305
+ theme: theme,
2306
+ layout: layout,
2307
+ direction: direction,
2308
+ responsive: responsive,
2309
+ state_radius: state_radius,
2310
+ auto_state_radius: auto_state_radius,
2311
+ min_state_radius: min_state_radius,
2312
+ max_state_radius: max_state_radius,
2313
+ state_shape: state_shape,
2314
+ state_stroke_width: state_stroke_width,
2315
+ transition_stroke_width: transition_stroke_width,
2316
+ padding: padding,
2317
+ node_spacing: node_spacing,
2318
+ rank_spacing: rank_spacing,
2319
+ force_iterations: force_iterations,
2320
+ layout_seed: layout_seed,
2321
+ graphviz_command: graphviz_command,
2322
+ auto_size: auto_size,
2323
+ auto_density_spacing: auto_density_spacing,
2324
+ arrow_size: arrow_size,
2325
+ arrow_shape: arrow_shape,
2326
+ initial_arrow_length: initial_arrow_length,
2327
+ initial_arrow_label: initial_arrow_label,
2328
+ final_arrow_length: final_arrow_length,
2329
+ final_arrow_label: final_arrow_label,
2330
+ initial_position: initial_position,
2331
+ final_position: final_position,
2332
+ merge_parallel_transitions: merge_parallel_transitions,
2333
+ label_background: label_background,
2334
+ label_border: label_border,
2335
+ label_padding: label_padding,
2336
+ label_radius: label_radius,
2337
+ rotate_labels: rotate_labels,
2338
+ highlight_unreachable: highlight_unreachable,
2339
+ highlight_dead_states: highlight_dead_states,
2340
+ highlight_initial_state: highlight_initial_state,
2341
+ highlight_final_states: highlight_final_states,
2342
+ highlight_transitions: highlight_transitions,
2343
+ unreachable_zone: unreachable_zone,
2344
+ xml_declaration: xml_declaration,
2345
+ css_variables: css_variables,
2346
+ embed_styles: embed_styles,
2347
+ pretty: pretty,
2348
+ minify: minify,
2349
+ state_effect: state_effect,
2350
+ loop_position: loop_position,
2351
+ edge_style: edge_style,
2352
+ show_final_arrows: show_final_arrows,
2353
+ scc_groups: scc_groups,
2354
+ fold_groups: fold_groups,
2355
+ preserve_manual_positions: preserve_manual_positions,
2356
+ fit: fit,
2357
+ wrap: wrap,
2358
+ max_transition_label_width: max_transition_label_width,
2359
+ state_wrap: state_wrap,
2360
+ max_state_label_width: max_state_label_width,
2361
+ sort_labels: sort_labels,
2362
+ label_tooltips: label_tooltips,
2363
+ html_tooltips: html_tooltips,
2364
+ font_family: font_family,
2365
+ state_font_weight: state_font_weight,
2366
+ transition_font_weight: transition_font_weight,
2367
+ title: title,
2368
+ description: description,
2369
+ svg_id: svg_id
2370
+ )
2371
+ )
2372
+ end
2373
+
2374
+ def to_png(width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2375
+ scale: Exporters::Png::DEFAULT_SCALE, converter: Exporters::Png::DEFAULT_CONVERTER, **svg_options)
2376
+ Exporters::Png.new(self).export(width, height, theme: theme, scale: scale, converter: converter, **svg_options)
2377
+ end
2378
+
2379
+ def save_png(filename, width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2380
+ scale: Exporters::Png::DEFAULT_SCALE, converter: Exporters::Png::DEFAULT_CONVERTER, **svg_options)
2381
+ AtomicFile.write(filename, to_png(width, height, theme: theme, scale: scale, converter: converter, **svg_options), binary: true)
2382
+ end
2383
+
2384
+ def to_pdf(width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2385
+ converter: Exporters::Pdf::DEFAULT_CONVERTER, **svg_options)
2386
+ Exporters::Pdf.new(self).export(width, height, theme: theme, converter: converter, **svg_options)
2387
+ end
2388
+
2389
+ def save_pdf(filename, width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2390
+ converter: Exporters::Pdf::DEFAULT_CONVERTER, **svg_options)
2391
+ AtomicFile.write(filename, to_pdf(width, height, theme: theme, converter: converter, **svg_options), binary: true)
2392
+ end
2393
+
2394
+ def to_webp(width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2395
+ converter: Exporters::Webp::DEFAULT_CONVERTER, **svg_options)
2396
+ Exporters::Webp.new(self).export(width, height, theme: theme, converter: converter, **svg_options)
2397
+ end
2398
+
2399
+ def save_webp(filename, width = 800, height = 600, theme: Exporters::Svg::DEFAULT_THEME,
2400
+ converter: Exporters::Webp::DEFAULT_CONVERTER, **svg_options)
2401
+ AtomicFile.write(filename, to_webp(width, height, theme: theme, converter: converter, **svg_options), binary: true)
2402
+ end
2403
+
2404
+ def to_mermaid(direction: Exporters::Mermaid::DEFAULT_DIRECTION, notes: Exporters::Mermaid::DEFAULT_NOTES,
2405
+ class_defs: Exporters::Mermaid::DEFAULT_CLASS_DEFS)
2406
+ Exporters::Mermaid.new(self, direction: direction, notes: notes, class_defs: class_defs).export
2407
+ end
2408
+
2409
+ def to_html(direction: Exporters::Mermaid::DEFAULT_DIRECTION, theme: Exporters::Mermaid::DEFAULT_THEME,
2410
+ cdn: Exporters::Mermaid::DEFAULT_CDN, inline_mermaid: false, offline: false, title: nil,
2411
+ lang: Exporters::Mermaid::DEFAULT_LANG, show_source: Exporters::Mermaid::DEFAULT_SHOW_SOURCE,
2412
+ pan_zoom: Exporters::Mermaid::DEFAULT_PAN_ZOOM,
2413
+ mathjax: Exporters::Mermaid::DEFAULT_MATHJAX,
2414
+ mathjax_cdn: Exporters::Mermaid::DEFAULT_MATHJAX_CDN,
2415
+ inline_mathjax: false, self_contained: false, nonce: nil, csp: false,
2416
+ mermaid_sha256: nil, mathjax_sha256: nil,
2417
+ notes: Exporters::Mermaid::DEFAULT_NOTES,
2418
+ class_defs: Exporters::Mermaid::DEFAULT_CLASS_DEFS)
2419
+ Exporters::Mermaid.new(self, direction: direction, notes: notes, class_defs: class_defs).export_html(
2420
+ theme: theme,
2421
+ cdn: cdn,
2422
+ inline_mermaid: inline_mermaid,
2423
+ offline: offline,
2424
+ title: title,
2425
+ lang: lang,
2426
+ show_source: show_source,
2427
+ pan_zoom: pan_zoom,
2428
+ mathjax: mathjax,
2429
+ mathjax_cdn: mathjax_cdn,
2430
+ inline_mathjax: inline_mathjax,
2431
+ self_contained: self_contained,
2432
+ nonce: nonce,
2433
+ csp: csp,
2434
+ mermaid_sha256: mermaid_sha256,
2435
+ mathjax_sha256: mathjax_sha256
2436
+ )
2437
+ end
2438
+
2439
+ def save_html(filename, direction: Exporters::Mermaid::DEFAULT_DIRECTION, theme: Exporters::Mermaid::DEFAULT_THEME,
2440
+ cdn: Exporters::Mermaid::DEFAULT_CDN, inline_mermaid: false, offline: false, title: nil,
2441
+ lang: Exporters::Mermaid::DEFAULT_LANG, show_source: Exporters::Mermaid::DEFAULT_SHOW_SOURCE,
2442
+ pan_zoom: Exporters::Mermaid::DEFAULT_PAN_ZOOM,
2443
+ mathjax: Exporters::Mermaid::DEFAULT_MATHJAX,
2444
+ mathjax_cdn: Exporters::Mermaid::DEFAULT_MATHJAX_CDN,
2445
+ inline_mathjax: false, self_contained: false, nonce: nil, csp: false,
2446
+ mermaid_sha256: nil, mathjax_sha256: nil,
2447
+ notes: Exporters::Mermaid::DEFAULT_NOTES,
2448
+ class_defs: Exporters::Mermaid::DEFAULT_CLASS_DEFS)
2449
+ AtomicFile.write(
2450
+ filename,
2451
+ to_html(
2452
+ direction: direction,
2453
+ theme: theme,
2454
+ cdn: cdn,
2455
+ inline_mermaid: inline_mermaid,
2456
+ offline: offline,
2457
+ title: title,
2458
+ lang: lang,
2459
+ show_source: show_source,
2460
+ pan_zoom: pan_zoom,
2461
+ mathjax: mathjax,
2462
+ mathjax_cdn: mathjax_cdn,
2463
+ inline_mathjax: inline_mathjax,
2464
+ self_contained: self_contained,
2465
+ nonce: nonce,
2466
+ csp: csp,
2467
+ mermaid_sha256: mermaid_sha256,
2468
+ mathjax_sha256: mathjax_sha256,
2469
+ notes: notes,
2470
+ class_defs: class_defs
2471
+ )
2472
+ )
2473
+ end
2474
+
2475
+ def to_dot(direction: Exporters::Dot::DEFAULT_DIRECTION, theme: nil,
2476
+ rank_constraints: Exporters::Dot::DEFAULT_RANK_CONSTRAINTS)
2477
+ Exporters::Dot.new(self, direction: direction, theme: theme, rank_constraints: rank_constraints).export
2478
+ end
2479
+
2480
+ def save_dot(filename, direction: Exporters::Dot::DEFAULT_DIRECTION, theme: nil,
2481
+ rank_constraints: Exporters::Dot::DEFAULT_RANK_CONSTRAINTS)
2482
+ AtomicFile.write(filename, to_dot(direction: direction, theme: theme, rank_constraints: rank_constraints))
2483
+ end
2484
+
2485
+ def to_plantuml(direction: Exporters::Plantuml::DEFAULT_DIRECTION, theme: nil,
2486
+ notes: Exporters::Plantuml::DEFAULT_NOTES)
2487
+ Exporters::Plantuml.new(self, direction: direction, theme: theme, notes: notes).export
2488
+ end
2489
+
2490
+ def save_plantuml(filename, direction: Exporters::Plantuml::DEFAULT_DIRECTION, theme: nil,
2491
+ notes: Exporters::Plantuml::DEFAULT_NOTES)
2492
+ AtomicFile.write(filename, to_plantuml(direction: direction, theme: theme, notes: notes))
2493
+ end
2494
+
2495
+ private :layout_linear_positions,
2496
+ :layout_circle_positions,
2497
+ :layout_grid_positions,
2498
+ :layout_layered_positions,
2499
+ :layout_layered_groups,
2500
+ :layout_force_positions,
2501
+ :layout_graphviz_positions,
2502
+ :ordered_state_names,
2503
+ :crossing_reduced_layer_groups,
2504
+ :order_layer_by_neighbor_barycenter,
2505
+ :layer_neighbor_positions,
2506
+ :weak_components,
2507
+ :layered_distances,
2508
+ :graphviz_layout_state_ids,
2509
+ :graphviz_layout_dot,
2510
+ :graphviz_command_args,
2511
+ :graphviz_rankdir,
2512
+ :parse_graphviz_plain_positions,
2513
+ :normalize_graphviz_positions
2514
+
2515
+ private
2516
+
2517
+ def validate_finite_number!(value, name, positive: false, nonnegative: false)
2518
+ finite = value.is_a?(Numeric) && value.real? && value.to_f.finite?
2519
+ valid_range = if positive
2520
+ finite && value.positive?
2521
+ elsif nonnegative
2522
+ finite && value >= 0
2523
+ else
2524
+ finite
2525
+ end
2526
+ return value if valid_range
2527
+
2528
+ qualifier = positive ? 'positive ' : (nonnegative ? 'non-negative ' : '')
2529
+ raise ArgumentError, "#{name} must be a #{qualifier}finite number"
2530
+ end
2531
+
2532
+ def resolve_layout(layout)
2533
+ resolved = layout.to_sym
2534
+ return resolved if LAYOUT_OPTIONS.include?(resolved)
2535
+
2536
+ raise ArgumentError, "Unknown SVG layout: #{layout.inspect}. Available layouts: #{LAYOUT_OPTIONS.join(', ')}"
2537
+ end
2538
+
2539
+ def resolve_direction(direction)
2540
+ resolved = direction.to_sym
2541
+ return resolved if DIRECTION_OPTIONS.include?(resolved)
2542
+
2543
+ raise ArgumentError, "Unknown direction: #{direction.inspect}. Available directions: #{DIRECTION_OPTIONS.join(', ')}"
2544
+ end
2545
+
2546
+ def resolve_fit(fit)
2547
+ resolved = fit.to_sym
2548
+ return resolved if FIT_OPTIONS.include?(resolved)
2549
+
2550
+ raise ArgumentError, "Unknown fit: #{fit.inspect}. Available values: #{FIT_OPTIONS.join(', ')}"
2551
+ end
2552
+
2553
+ def resolve_state_kind(kind)
2554
+ return nil if kind.nil?
2555
+
2556
+ resolved = kind.to_sym
2557
+ return resolved if STATE_KIND_OPTIONS.include?(resolved)
2558
+
2559
+ raise ArgumentError, "Unknown state kind: #{kind.inspect}. Available values: #{STATE_KIND_OPTIONS.join(', ')}"
2560
+ end
2561
+
2562
+ def resolve_initial_position(initial_position)
2563
+ resolved = initial_position.to_sym
2564
+ return resolved if INITIAL_POSITION_OPTIONS.include?(resolved)
2565
+
2566
+ raise ArgumentError, "Unknown initial_position: #{initial_position.inspect}. Available values: #{INITIAL_POSITION_OPTIONS.join(', ')}"
2567
+ end
2568
+
2569
+ def resolve_final_position(final_position)
2570
+ resolved = final_position.to_sym
2571
+ return resolved if FINAL_POSITION_OPTIONS.include?(resolved)
2572
+
2573
+ raise ArgumentError, "Unknown final_position: #{final_position.inspect}. Available values: #{FINAL_POSITION_OPTIONS.join(', ')}"
2574
+ end
2575
+
2576
+ def resolve_format(format)
2577
+ self.class::EXPORTERS.resolve(format)
2578
+ end
2579
+
2580
+ def normalize_transition_label(label, epsilon_label: DEFAULT_EPSILON_LABEL, sort_labels: false)
2581
+ if label.is_a?(Array)
2582
+ labels = label.map { |item| normalize_single_transition_label(item, epsilon_label: epsilon_label) }.uniq
2583
+ labels = labels.sort_by(&:to_s) if sort_labels
2584
+ if labels.any? { |item| item.is_a?(Label) && item.kind == :epsilon }
2585
+ return labels.first if labels.one?
2586
+
2587
+ return Label.alternatives(*labels)
2588
+ end
2589
+ return Label.symbols(*labels.map(&:to_s))
2590
+ end
2591
+
2592
+ normalize_single_transition_label(label, epsilon_label: epsilon_label)
2593
+ end
2594
+
2595
+ def normalize_single_transition_label(label, epsilon_label: DEFAULT_EPSILON_LABEL)
2596
+ return label if label.is_a?(Label)
2597
+ return Label.epsilon(epsilon_label) if label == :epsilon
2598
+
2599
+ label
2600
+ end
2601
+
2602
+ def deep_copy(value, copies = {})
2603
+ case value
2604
+ when Hash
2605
+ return copies[value.object_id] if copies.key?(value.object_id)
2606
+
2607
+ copy = {}
2608
+ copies[value.object_id] = copy
2609
+ value.each { |key, item| copy[deep_copy(key, copies)] = deep_copy(item, copies) }
2610
+ copy
2611
+ when Array
2612
+ return copies[value.object_id] if copies.key?(value.object_id)
2613
+
2614
+ copy = []
2615
+ copies[value.object_id] = copy
2616
+ value.each { |item| copy << deep_copy(item, copies) }
2617
+ copy
2618
+ when String
2619
+ value.dup
2620
+ else
2621
+ value
2622
+ end
2623
+ end
2624
+
2625
+ def immutable_copy(value)
2626
+ copy = deep_copy(value)
2627
+ deep_freeze(copy)
2628
+ end
2629
+
2630
+ def immutable_snapshot(value)
2631
+ immutable_copy(value)
2632
+ end
2633
+
2634
+ def deep_freeze(value)
2635
+ stack = [value]
2636
+ visited = {}
2637
+ until stack.empty?
2638
+ current = stack.pop
2639
+ next unless current.is_a?(Hash) || current.is_a?(Array) || current.is_a?(String)
2640
+ next if visited[current.object_id]
2641
+
2642
+ visited[current.object_id] = true
2643
+ if current.is_a?(Hash)
2644
+ current.each { |key, item| stack << key << item }
2645
+ elsif current.is_a?(Array)
2646
+ current.each { |item| stack << item }
2647
+ end
2648
+ current.freeze
2649
+ end
2650
+ value.freeze
2651
+ end
2652
+
2653
+ def reference_diagnostics
2654
+ diagnostics = []
2655
+ if @initial_state && !@states.key?(@initial_state)
2656
+ diagnostics << diagnostic(
2657
+ 'undefined-initial-state',
2658
+ :error,
2659
+ ['initial'],
2660
+ "Initial state #{@initial_state.inspect} is not defined"
2661
+ )
2662
+ end
2663
+ @final_states.each_with_index do |state, index|
2664
+ next if @states.key?(state)
2665
+
2666
+ diagnostics << diagnostic(
2667
+ 'undefined-final-state',
2668
+ :error,
2669
+ ['final', index],
2670
+ "Final state #{state.inspect} is not defined"
2671
+ )
2672
+ end
2673
+ @transitions.each_with_index do |transition, index|
2674
+ unless @states.key?(transition.from)
2675
+ diagnostics << diagnostic(
2676
+ 'undefined-transition-source',
2677
+ :error,
2678
+ ['transitions', index, 'from'],
2679
+ "Transition #{index} source #{transition.from.inspect} is not defined"
2680
+ )
2681
+ end
2682
+ next if @states.key?(transition.to)
2683
+
2684
+ diagnostics << diagnostic(
2685
+ 'undefined-transition-target',
2686
+ :error,
2687
+ ['transitions', index, 'to'],
2688
+ "Transition #{index} target #{transition.to.inspect} is not defined"
2689
+ )
2690
+ end
2691
+ hierarchy_validation_errors.each do |message|
2692
+ diagnostics << diagnostic('invalid-state-hierarchy', :error, ['states'], message)
2693
+ end
2694
+ diagnostics
2695
+ end
2696
+
2697
+ def fsm_semantic_diagnostics
2698
+ diagnostics = []
2699
+ if @initial_state.nil?
2700
+ diagnostics << diagnostic(
2701
+ 'missing-initial-state',
2702
+ :warning,
2703
+ ['initial'],
2704
+ 'Automaton has no initial state',
2705
+ 'Set an initial state before using reachability analysis.'
2706
+ )
2707
+ end
2708
+ if @final_states.empty?
2709
+ diagnostics << diagnostic(
2710
+ 'missing-final-state',
2711
+ :warning,
2712
+ ['final'],
2713
+ 'Automaton has no final states',
2714
+ 'dead_states is empty when no accepting states are defined.'
2715
+ )
2716
+ end
2717
+ diagnostics
2718
+ end
2719
+
2720
+ def dfa_diagnostics
2721
+ ensure_analysis_index!
2722
+ diagnostics = []
2723
+ @states.each_key do |from|
2724
+ by_symbol = Hash.new { |hash, key| hash[key] = [] }
2725
+ @outgoing_by_state[from].each do |transition|
2726
+ if epsilon_transition_label?(transition.label)
2727
+ diagnostics << diagnostic(
2728
+ 'epsilon-transition-in-dfa',
2729
+ :error,
2730
+ ['transitions', transition.id],
2731
+ "State #{from.inspect} has an epsilon transition"
2732
+ )
2733
+ end
2734
+ transition_label_symbols(transition.label).each { |symbol| by_symbol[symbol] << transition }
2735
+ end
2736
+ by_symbol.each do |symbol, transitions|
2737
+ next unless transitions.map(&:to).uniq.size > 1
2738
+
2739
+ diagnostics << diagnostic(
2740
+ 'nondeterministic-transition',
2741
+ :error,
2742
+ ['states', from],
2743
+ "State #{from.inspect} has multiple targets for label #{symbol.inspect}"
2744
+ )
2745
+ end
2746
+ end
2747
+ diagnostics.uniq(&:message)
2748
+ end
2749
+
2750
+ def transition_label_symbols(label)
2751
+ if label.is_a?(Label)
2752
+ return label.value if label.kind == :symbols
2753
+ return [] if label.kind == :epsilon
2754
+ return label.value.flat_map { |item| transition_label_symbols(item) } if label.kind == :alternatives
2755
+ end
2756
+
2757
+ [label.to_s]
2758
+ end
2759
+
2760
+ def epsilon_transition_label?(label)
2761
+ return false unless label.is_a?(Label)
2762
+ return true if label.kind == :epsilon
2763
+
2764
+ label.kind == :alternatives && label.value.any? { |item| epsilon_transition_label?(item) }
2765
+ end
2766
+
2767
+ def diagnostic(code, severity, path, message, hint = nil)
2768
+ Diagnostic.new(code: code, severity: severity, path: path.freeze, message: message, hint: hint)
2769
+ end
2770
+
2771
+ def ensure_analysis_index!
2772
+ return if @analysis_index_revision == @revision
2773
+
2774
+ @outgoing_by_state = @states.each_key.to_h { |state| [state, []] }
2775
+ @incoming_by_state = @states.each_key.to_h { |state| [state, []] }
2776
+ @undirected_neighbors = @states.each_key.to_h { |state| [state, []] }
2777
+ @transitions_by_directed_pair = Hash.new { |hash, key| hash[key] = [] }
2778
+ @transitions_by_undirected_pair = Hash.new { |hash, key| hash[key] = [] }
2779
+ @transitions.each do |transition|
2780
+ @transitions_by_directed_pair[[transition.from, transition.to]] << transition
2781
+ @transitions_by_undirected_pair[Set[transition.from, transition.to].freeze] << transition
2782
+ next unless @states.key?(transition.from) && @states.key?(transition.to)
2783
+
2784
+ @outgoing_by_state[transition.from] << transition
2785
+ @incoming_by_state[transition.to] << transition
2786
+ @undirected_neighbors[transition.from] << transition.to unless @undirected_neighbors[transition.from].include?(transition.to)
2787
+ @undirected_neighbors[transition.to] << transition.from unless @undirected_neighbors[transition.to].include?(transition.from)
2788
+ end
2789
+ @analysis_index_revision = @revision
2790
+ end
2791
+
2792
+ def transition_index(identifier)
2793
+ transition_id = identifier.is_a?(Transition) ? identifier.id : identifier
2794
+ index = @transitions.index { |transition| transition.id == transition_id }
2795
+ return index if index
2796
+
2797
+ raise ArgumentError, "Transition is not defined: #{identifier.inspect}"
2798
+ end
2799
+
2800
+ def graph_changed!
2801
+ @revision += 1
2802
+ @analysis_index_revision = nil
2803
+ @state_positions = {}
2804
+ @layout_cache.clear
2805
+ self
2806
+ end
2807
+
2808
+ def hierarchy_validation_errors
2809
+ errors = []
2810
+ parents = {}
2811
+
2812
+ @states.each do |name, state|
2813
+ metadata = state[:metadata]
2814
+ next unless metadata.is_a?(Hash)
2815
+
2816
+ parent = metadata[:parent] || metadata['parent']
2817
+ group = metadata[:group] || metadata['group'] || metadata[:cluster] || metadata['cluster']
2818
+ errors << "State #{name.inspect} cannot define both parent and group" if parent && group
2819
+ next unless parent
2820
+
2821
+ unless @states.key?(parent)
2822
+ errors << "State #{name.inspect} parent #{parent.inspect} is not defined"
2823
+ next
2824
+ end
2825
+ parents[name] = parent
2826
+ end
2827
+
2828
+ reported = {}
2829
+ parents.each_key do |start|
2830
+ path = []
2831
+ indexes = {}
2832
+ current = start
2833
+ while parents.key?(current)
2834
+ if indexes.key?(current)
2835
+ cycle = path[indexes[current]..] + [current]
2836
+ key = cycle[0...-1].to_h { |state| [state, true] }
2837
+ unless key.keys.any? { |state| reported[state] }
2838
+ errors << "State hierarchy contains a cycle: #{cycle.map(&:inspect).join(' -> ')}"
2839
+ key.each_key { |state| reported[state] = true }
2840
+ end
2841
+ break
2842
+ end
2843
+
2844
+ indexes[current] = path.length
2845
+ path << current
2846
+ current = parents[current]
2847
+ end
2848
+ end
2849
+
2850
+ errors
2851
+ end
2852
+
2853
+ def fit_positions(positions, width, height, state_radius, padding, fit)
2854
+ return positions if positions.empty?
2855
+
2856
+ margin = [padding.to_f, state_radius.to_f].max
2857
+ target_width = width.to_f - (2 * margin)
2858
+ target_height = height.to_f - (2 * margin)
2859
+ center_x = width.to_f / 2.0
2860
+ center_y = height.to_f / 2.0
2861
+
2862
+ if target_width <= 0 || target_height <= 0
2863
+ return positions.transform_values { { x: center_x, y: center_y } }
2864
+ end
2865
+
2866
+ x_values = positions.values.map { |position| position[:x].to_f }
2867
+ y_values = positions.values.map { |position| position[:y].to_f }
2868
+ min_x, max_x = x_values.minmax
2869
+ min_y, max_y = y_values.minmax
2870
+ span_x = max_x - min_x
2871
+ span_y = max_y - min_y
2872
+
2873
+ if span_x.zero? && span_y.zero?
2874
+ return positions.transform_values { { x: center_x, y: center_y } }
2875
+ end
2876
+
2877
+ scale_x = span_x.zero? ? nil : target_width / span_x
2878
+ scale_y = span_y.zero? ? nil : target_height / span_y
2879
+ return cover_positions(positions, min_x, min_y, span_x, span_y, margin, center_x, center_y, scale_x, scale_y) if fit == :cover
2880
+
2881
+ scale = [scale_x || Float::INFINITY, scale_y || Float::INFINITY].min
2882
+ scaled_width = span_x * scale
2883
+ scaled_height = span_y * scale
2884
+ offset_x = margin + ((target_width - scaled_width) / 2.0)
2885
+ offset_y = margin + ((target_height - scaled_height) / 2.0)
2886
+
2887
+ positions.transform_values do |position|
2888
+ {
2889
+ x: span_x.zero? ? center_x : offset_x + ((position[:x].to_f - min_x) * scale),
2890
+ y: span_y.zero? ? center_y : offset_y + ((position[:y].to_f - min_y) * scale)
2891
+ }
2892
+ end
2893
+ end
2894
+
2895
+ def cover_positions(positions, min_x, min_y, span_x, span_y, margin, center_x, center_y, scale_x, scale_y)
2896
+ positions.transform_values do |position|
2897
+ {
2898
+ x: span_x.zero? ? center_x : margin + ((position[:x].to_f - min_x) * scale_x),
2899
+ y: span_y.zero? ? center_y : margin + ((position[:y].to_f - min_y) * scale_y)
2900
+ }
2901
+ end
2902
+ end
2903
+
2904
+ def arrange_auto_states(auto_states, initial_position:, final_position:)
2905
+ ordered = auto_states.uniq
2906
+
2907
+ if initial_position == :start && @initial_state && ordered.include?(@initial_state)
2908
+ ordered.delete(@initial_state)
2909
+ ordered.unshift(@initial_state)
2910
+ end
2911
+
2912
+ return ordered unless final_position == :end
2913
+
2914
+ non_final_states = ordered.reject { |name| @final_states.include?(name) }
2915
+ final_states = ordered.select { |name| @final_states.include?(name) }
2916
+ non_final_states + final_states
2917
+ end
2918
+
2919
+ def avoid_fixed_position_collisions(auto_positions, fixed_positions, width, height, state_radius, padding, spacing, direction)
2920
+ return auto_positions if fixed_positions.empty? || auto_positions.empty?
2921
+
2922
+ minimum_distance = [spacing.to_f, state_radius.to_f * 2.5].max
2923
+ occupied = fixed_positions.values.map(&:dup)
2924
+ auto_positions.each_with_object({}) do |(name, position), adjusted|
2925
+ candidate = position.dup
2926
+ if position_collides?(candidate, occupied, minimum_distance)
2927
+ offsets = collision_avoidance_offsets(occupied.size + auto_positions.size + 1, minimum_distance, direction)
2928
+ candidates = offsets.map { |offset_x, offset_y| { x: position[:x] + offset_x, y: position[:y] + offset_y } }
2929
+ candidate = candidates.find do |item|
2930
+ position_inside_canvas?(item, width, height, state_radius, padding) &&
2931
+ !position_collides?(item, occupied, minimum_distance)
2932
+ end
2933
+ candidate ||= candidates.find { |item| !position_collides?(item, occupied, minimum_distance) }
2934
+ candidate ||= position
2935
+ end
2936
+
2937
+ adjusted[name] = candidate
2938
+ occupied << candidate
2939
+ end
2940
+ end
2941
+
2942
+ def collision_avoidance_offsets(rings, spacing, direction)
2943
+ (1..rings).flat_map do |ring|
2944
+ distance = spacing * ring
2945
+ if %i[lr rl].include?(direction)
2946
+ [[0, -distance], [0, distance], [-distance, 0], [distance, 0],
2947
+ [-distance, -distance], [distance, -distance], [-distance, distance], [distance, distance]]
2948
+ else
2949
+ [[-distance, 0], [distance, 0], [0, -distance], [0, distance],
2950
+ [-distance, -distance], [-distance, distance], [distance, -distance], [distance, distance]]
2951
+ end
2952
+ end
2953
+ end
2954
+
2955
+ def position_collides?(position, occupied, minimum_distance)
2956
+ occupied.any? do |other|
2957
+ Math.hypot(position[:x] - other[:x], position[:y] - other[:y]) < minimum_distance
2958
+ end
2959
+ end
2960
+
2961
+ def position_inside_canvas?(position, width, height, state_radius, padding)
2962
+ margin = [state_radius.to_f, padding.to_f].max
2963
+ position[:x] >= margin && position[:x] <= width.to_f - margin &&
2964
+ position[:y] >= margin && position[:y] <= height.to_f - margin
2965
+ end
2966
+
2967
+ def manual_position?(state)
2968
+ @manual_states[state]
2969
+ end
2970
+
2971
+ def layout_linear_position(index, _count, width, height, margin, horizontal_step, vertical_step, direction)
2972
+ case direction
2973
+ when :lr
2974
+ x = margin + (index * horizontal_step)
2975
+ y = height / 2.0
2976
+ when :rl
2977
+ x = width - margin - (index * horizontal_step)
2978
+ y = height / 2.0
2979
+ when :tb
2980
+ x = width / 2.0
2981
+ y = margin + (index * vertical_step)
2982
+ when :bt
2983
+ x = width / 2.0
2984
+ y = height - margin - (index * vertical_step)
2985
+ end
2986
+
2987
+ { x: x, y: y }
109
2988
  end
110
2989
  end