graphomaton 1.0.0 → 1.1.0

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