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
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ require_relative 'svg'
6
+
7
+ class Graphomaton
8
+ module Exporters
9
+ class Webp
10
+ include Graphomaton::ExporterIntrospection
11
+ class ConversionError < Graphomaton::ConversionError; end
12
+
13
+ DEFAULT_CONVERTER = :auto
14
+ DEFAULT_TIMEOUT = ProcessRunner::DEFAULT_TIMEOUT
15
+ DEFAULT_MAX_OUTPUT_BYTES = ProcessRunner::DEFAULT_MAX_STDOUT_BYTES
16
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
17
+
18
+ CONVERTER_COMMANDS = {
19
+ rsvg_magick: [
20
+ ['rsvg-convert', '--format', 'png', '-'],
21
+ ['magick', 'png:-', 'webp:-']
22
+ ],
23
+ magick: ['magick', 'svg:-', 'webp:-'],
24
+ convert: ['convert', 'svg:-', 'webp:-']
25
+ }.freeze
26
+ CONVERTER_OPTIONS = ([:auto] + CONVERTER_COMMANDS.keys).freeze
27
+
28
+ def self.available?(converter: DEFAULT_CONVERTER)
29
+ !available_command(converter: converter).nil?
30
+ end
31
+
32
+ def self.available_command(converter: DEFAULT_CONVERTER)
33
+ resolved_converter = resolve_converter(converter)
34
+ if resolved_converter != :auto
35
+ command = CONVERTER_COMMANDS[resolved_converter]
36
+ return command if command_available?(command)
37
+ end
38
+ return nil if resolved_converter != :auto
39
+
40
+ CONVERTER_COMMANDS.values.find { |command| command_available?(command) }
41
+ end
42
+
43
+ def initialize(automaton)
44
+ @automaton = automaton
45
+ end
46
+
47
+ def export(width = 800, height = 600, theme: Svg::DEFAULT_THEME, converter: DEFAULT_CONVERTER,
48
+ timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
49
+ export_result(
50
+ width,
51
+ height,
52
+ theme: theme,
53
+ converter: converter,
54
+ timeout: timeout,
55
+ max_output_bytes: max_output_bytes,
56
+ **svg_options
57
+ ).output
58
+ end
59
+
60
+ def export_result(width = 800, height = 600, theme: Svg::DEFAULT_THEME, converter: DEFAULT_CONVERTER,
61
+ timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
62
+ command = available_command(converter: converter)
63
+ raise ConversionError, missing_converter_message(converter) unless command
64
+
65
+ svg_result = Svg.new(@automaton).export_result(width, height, theme: theme, **svg_options)
66
+ webp, error, status = run_conversion(
67
+ command,
68
+ svg_result.output,
69
+ timeout: timeout,
70
+ max_output_bytes: max_output_bytes
71
+ )
72
+ webp = webp.b
73
+
74
+ if status.success? && webp?(webp)
75
+ return RenderResult.new(
76
+ output: webp.freeze,
77
+ diagnostics: svg_result.diagnostics,
78
+ bounds: svg_result.bounds,
79
+ layout: svg_result.layout
80
+ )
81
+ end
82
+ raise ConversionError, invalid_webp_message(command, error) if status.success?
83
+
84
+ raise ConversionError, failed_conversion_message(command, error)
85
+ rescue ProcessRunner::Error => e
86
+ raise ConversionError, failed_conversion_message(command, e.message)
87
+ end
88
+
89
+ private
90
+
91
+ def available_command(converter: DEFAULT_CONVERTER)
92
+ self.class.available_command(converter: converter)
93
+ end
94
+
95
+ def self.executable?(command)
96
+ !ProcessRunner.which(command).nil?
97
+ end
98
+
99
+ def self.command_available?(command)
100
+ stages = command.first.is_a?(Array) ? command : [command]
101
+ stages.all? { |stage| executable?(stage.first) }
102
+ end
103
+
104
+ def self.resolve_converter(converter)
105
+ resolved = converter.to_sym
106
+ return resolved if CONVERTER_OPTIONS.include?(resolved)
107
+
108
+ raise ArgumentError, "Unknown WebP converter: #{converter.inspect}. Available converters: #{CONVERTER_OPTIONS.join(', ')}"
109
+ end
110
+
111
+ def webp?(data)
112
+ data.start_with?('RIFF') && data.byteslice(8, 4) == 'WEBP'
113
+ end
114
+
115
+ def run_conversion(command, svg, timeout:, max_output_bytes:)
116
+ unless command.first.is_a?(Array)
117
+ return ProcessRunner.capture3(
118
+ *command,
119
+ stdin_data: svg,
120
+ binmode: true,
121
+ timeout: timeout,
122
+ max_stdout_bytes: max_output_bytes
123
+ )
124
+ end
125
+
126
+ raster_command, webp_command = command
127
+ png, raster_error, raster_status = ProcessRunner.capture3(
128
+ *raster_command,
129
+ stdin_data: svg,
130
+ binmode: true,
131
+ timeout: timeout,
132
+ max_stdout_bytes: max_output_bytes
133
+ )
134
+ unless raster_status.success? && png.b.start_with?(PNG_SIGNATURE)
135
+ detail = raster_error.to_s.strip
136
+ detail = 'converter did not produce PNG data' if detail.empty?
137
+ raise ConversionError, "Failed to rasterize SVG using #{raster_command.first}: #{detail}"
138
+ end
139
+
140
+ ProcessRunner.capture3(
141
+ *webp_command,
142
+ stdin_data: png,
143
+ binmode: true,
144
+ timeout: timeout,
145
+ max_stdout_bytes: max_output_bytes
146
+ )
147
+ end
148
+
149
+ def missing_converter_message(converter)
150
+ resolved_converter = self.class.resolve_converter(converter)
151
+ required = if resolved_converter == :auto
152
+ 'magick or convert (optionally with rsvg-convert)'
153
+ else
154
+ CONVERTER_COMMANDS[resolved_converter].first
155
+ end
156
+
157
+ "WebP export requires #{required} to be installed. #{install_hint}"
158
+ end
159
+
160
+ def install_hint
161
+ 'Install hints: macOS: brew install imagemagick librsvg; Debian/Ubuntu: apt install imagemagick librsvg2-bin; Windows: install ImageMagick.'
162
+ end
163
+
164
+ def failed_conversion_message(command, error)
165
+ detail = error.to_s.strip
166
+ detail = 'unknown error' if detail.empty?
167
+
168
+ "Failed to convert SVG to WebP using #{converter_name(command)}: #{detail}"
169
+ end
170
+
171
+ def invalid_webp_message(command, error)
172
+ detail = error.to_s.strip
173
+ return "Failed to convert SVG to WebP using #{converter_name(command)}: converter did not produce WebP data" if detail.empty?
174
+
175
+ "Failed to convert SVG to WebP using #{converter_name(command)}: converter did not produce WebP data (#{detail})"
176
+ end
177
+
178
+ def converter_name(command)
179
+ return command.first unless command.first.is_a?(Array)
180
+
181
+ command.map(&:first).join(' + ')
182
+ end
183
+ end
184
+ end
185
+ end
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'exporters/svg'
4
- require_relative 'exporters/mermaid'
5
- require_relative 'exporters/dot'
6
- require_relative 'exporters/plantuml'
3
+ class Graphomaton
4
+ module Exporters
5
+ autoload :Svg, File.expand_path('exporters/svg', __dir__)
6
+ autoload :Png, File.expand_path('exporters/png', __dir__)
7
+ autoload :Pdf, File.expand_path('exporters/pdf', __dir__)
8
+ autoload :Webp, File.expand_path('exporters/webp', __dir__)
9
+ autoload :Mermaid, File.expand_path('exporters/mermaid', __dir__)
10
+ autoload :Dot, File.expand_path('exporters/dot', __dir__)
11
+ autoload :Plantuml, File.expand_path('exporters/plantuml', __dir__)
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ # Assigns deterministic, collision-free identifiers within one export.
5
+ class IdentifierAllocator
6
+ def initialize(reserved: [])
7
+ @identifiers = {}
8
+ @used = reserved.to_h { |identifier| [identifier.to_s, true] }
9
+ @counters = Hash.new(0)
10
+ end
11
+
12
+ def allocate(key, preferred: nil, prefix: 'id')
13
+ return @identifiers[key] if @identifiers.key?(key)
14
+
15
+ candidate = preferred.to_s unless preferred.nil?
16
+ candidate = nil if candidate&.empty? || @used.key?(candidate)
17
+ candidate ||= next_identifier(prefix)
18
+
19
+ @used[candidate] = true
20
+ @identifiers[key] = candidate
21
+ end
22
+
23
+ private
24
+
25
+ def next_identifier(prefix)
26
+ loop do
27
+ @counters[prefix] += 1
28
+ candidate = "#{prefix}_#{@counters[prefix]}"
29
+ return candidate unless @used.key?(candidate)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ # Validates untrusted model input before it reaches exporters or graph analysis.
5
+ class InputPolicy
6
+ XML_INVALID_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFE\uFFFF]/
7
+ TOP_LEVEL_KEYS = %i[version states transitions initial initial_state final final_states].freeze
8
+ STATE_KEYS = %i[id name x y label style metadata shape kind initial final accepting].freeze
9
+ TRANSITION_KEYS = %i[from to label style metadata line_style].freeze
10
+
11
+ def self.text!(value, context:, max_bytes: nil)
12
+ return value unless value.is_a?(String)
13
+
14
+ unless value.encoding == Encoding::UTF_8 ? value.valid_encoding? : value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
15
+ raise ArgumentError, "#{context} must be valid UTF-8"
16
+ end
17
+ utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
18
+ raise ArgumentError, "#{context} contains characters that are invalid in XML" if utf8.match?(XML_INVALID_CHARACTERS)
19
+ if max_bytes && utf8.bytesize > max_bytes
20
+ raise ArgumentError, "#{context} exceeds max_label_length (#{max_bytes})"
21
+ end
22
+
23
+ value
24
+ end
25
+
26
+ def self.identifier!(value, context:)
27
+ raise ArgumentError, "#{context} cannot be nil" if value.nil?
28
+
29
+ text!(value, context: context)
30
+ value
31
+ end
32
+
33
+ def self.label!(value, context:, max_bytes: nil)
34
+ if value.is_a?(Hash) || value.is_a?(Array)
35
+ raise ArgumentError, "#{context} must be scalar text or a Graphomaton::Label"
36
+ end
37
+
38
+ text!(value.to_s, context: context, max_bytes: max_bytes) unless value.nil?
39
+ value
40
+ end
41
+
42
+ def self.mapping!(value, context:)
43
+ return value if value.nil? || value.is_a?(Hash)
44
+
45
+ raise ArgumentError, "#{context} must be a Hash"
46
+ end
47
+
48
+ def self.boolean!(value, context:)
49
+ return value if value == true || value == false || value.nil?
50
+
51
+ raise ArgumentError, "#{context} must be true or false"
52
+ end
53
+
54
+ def self.known_keys!(hash, allowed, context:, strict:)
55
+ return unless strict
56
+
57
+ unknown = hash.keys.reject { |key| allowed.include?(key.to_s.to_sym) }
58
+ return if unknown.empty?
59
+
60
+ raise ArgumentError, "Unknown #{context} keys: #{unknown.join(', ')}"
61
+ end
62
+
63
+ def self.nested_depth!(value, maximum:, context:, max_string_bytes: nil)
64
+ raise ArgumentError, 'max_metadata_depth must be a positive Integer' unless maximum.is_a?(Integer) && maximum.positive?
65
+
66
+ stack = [[value, 1]]
67
+ visited = {}
68
+ until stack.empty?
69
+ current, depth = stack.pop
70
+ text!(current, context: context, max_bytes: max_string_bytes) if current.is_a?(String)
71
+ next unless current.is_a?(Hash) || current.is_a?(Array)
72
+ next if visited[current.object_id]
73
+
74
+ raise ArgumentError, "#{context} exceeds max_metadata_depth (#{maximum})" if depth > maximum
75
+
76
+ visited[current.object_id] = true
77
+ children = current.is_a?(Hash) ? current.flat_map { |key, item| [key, item] } : current
78
+ children.each { |child| stack << [child, depth + 1] }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ module Layout
5
+ class ForceTree
6
+ DEFAULT_THETA = 0.6
7
+ LEAF_CAPACITY = 4
8
+ MAX_DEPTH = 20
9
+
10
+ Node = Struct.new(
11
+ :left, :top, :size, :mass, :center_x, :center_y, :points, :children,
12
+ keyword_init: true
13
+ )
14
+
15
+ def initialize(positions)
16
+ @root = build_root(positions)
17
+ positions.each { |name, position| insert(@root, name, position, 0) }
18
+ end
19
+
20
+ def force_on(name, position, coefficient, theta: DEFAULT_THETA, &coincident_delta)
21
+ force_from_node(@root, name, position, coefficient.to_f, theta.to_f, coincident_delta)
22
+ end
23
+
24
+ private
25
+
26
+ def build_root(positions)
27
+ coordinates = positions.values
28
+ return Node.new(left: 0.0, top: 0.0, size: 1.0, mass: 0, center_x: 0.0, center_y: 0.0, points: []) if coordinates.empty?
29
+
30
+ xs = coordinates.map { |position| position[:x].to_f }
31
+ ys = coordinates.map { |position| position[:y].to_f }
32
+ min_x, max_x = xs.minmax
33
+ min_y, max_y = ys.minmax
34
+ size = [max_x - min_x, max_y - min_y, 1.0].max
35
+ Node.new(left: min_x, top: min_y, size: size, mass: 0, center_x: 0.0, center_y: 0.0, points: [])
36
+ end
37
+
38
+ def insert(node, name, position, depth)
39
+ x = position[:x].to_f
40
+ y = position[:y].to_f
41
+ previous_mass = node.mass
42
+ node.mass += 1
43
+ node.center_x = ((node.center_x * previous_mass) + x) / node.mass
44
+ node.center_y = ((node.center_y * previous_mass) + y) / node.mass
45
+
46
+ if node.children
47
+ insert(child_for(node, x, y), name, position, depth + 1)
48
+ elsif node.points.size < LEAF_CAPACITY || depth >= MAX_DEPTH
49
+ node.points << [name, position]
50
+ else
51
+ existing = node.points
52
+ node.points = []
53
+ node.children = subdivide(node)
54
+ existing.each { |point_name, point| insert_without_mass(child_for(node, point[:x], point[:y]), point_name, point, depth + 1) }
55
+ insert_without_mass(child_for(node, x, y), name, position, depth + 1)
56
+ end
57
+ end
58
+
59
+ def insert_without_mass(node, name, position, depth)
60
+ insert(node, name, position, depth)
61
+ end
62
+
63
+ def subdivide(node)
64
+ half = node.size / 2.0
65
+ [
66
+ Node.new(left: node.left, top: node.top, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: []),
67
+ Node.new(left: node.left + half, top: node.top, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: []),
68
+ Node.new(left: node.left, top: node.top + half, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: []),
69
+ Node.new(left: node.left + half, top: node.top + half, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: [])
70
+ ]
71
+ end
72
+
73
+ def child_for(node, x, y)
74
+ horizontal = x.to_f >= node.left + (node.size / 2.0) ? 1 : 0
75
+ vertical = y.to_f >= node.top + (node.size / 2.0) ? 1 : 0
76
+ node.children[(vertical * 2) + horizontal]
77
+ end
78
+
79
+ def force_from_node(node, name, position, coefficient, theta, coincident_delta)
80
+ return [0.0, 0.0] if node.mass.zero?
81
+
82
+ if node.children.nil?
83
+ return node.points.each_with_object([0.0, 0.0]) do |(other_name, other_position), force|
84
+ next if other_name == name
85
+
86
+ delta_x = position[:x].to_f - other_position[:x].to_f
87
+ delta_y = position[:y].to_f - other_position[:y].to_f
88
+ if delta_x.zero? && delta_y.zero?
89
+ delta_x, delta_y = coincident_delta.call(name, other_name)
90
+ end
91
+ add_repulsion(force, delta_x, delta_y, coefficient, 1)
92
+ end
93
+ end
94
+
95
+ delta_x = position[:x].to_f - node.center_x
96
+ delta_y = position[:y].to_f - node.center_y
97
+ distance = Math.hypot(delta_x, delta_y)
98
+ contains_target = contains?(node, position[:x].to_f, position[:y].to_f)
99
+ if !contains_target && distance.positive? && (node.size / distance) < theta
100
+ force = [0.0, 0.0]
101
+ add_repulsion(force, delta_x, delta_y, coefficient, node.mass)
102
+ return force
103
+ end
104
+
105
+ node.children.each_with_object([0.0, 0.0]) do |child, force|
106
+ child_force = force_from_node(child, name, position, coefficient, theta, coincident_delta)
107
+ force[0] += child_force[0]
108
+ force[1] += child_force[1]
109
+ end
110
+ end
111
+
112
+ def contains?(node, x, y)
113
+ x >= node.left && x <= node.left + node.size && y >= node.top && y <= node.top + node.size
114
+ end
115
+
116
+ def add_repulsion(force, delta_x, delta_y, coefficient, mass)
117
+ distance = Math.hypot(delta_x, delta_y)
118
+ return force unless distance.positive?
119
+
120
+ magnitude = coefficient * mass / distance
121
+ force[0] += (delta_x / distance) * magnitude
122
+ force[1] += (delta_y / distance) * magnitude
123
+ force
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ class Graphomaton
6
+ module ModelValue
7
+ def self.copy(value, copies = {})
8
+ case value
9
+ when Hash
10
+ return copies[value.object_id] if copies.key?(value.object_id)
11
+
12
+ duplicate = {}
13
+ copies[value.object_id] = duplicate
14
+ value.each { |key, item| duplicate[copy(key, copies)] = copy(item, copies) }
15
+ duplicate.freeze
16
+ when Array
17
+ return copies[value.object_id] if copies.key?(value.object_id)
18
+
19
+ duplicate = []
20
+ copies[value.object_id] = duplicate
21
+ value.each { |item| duplicate << copy(item, copies) }
22
+ duplicate.freeze
23
+ when Set
24
+ return copies[value.object_id] if copies.key?(value.object_id)
25
+
26
+ duplicate = Set.new
27
+ copies[value.object_id] = duplicate
28
+ value.each { |item| duplicate << copy(item, copies) }
29
+ duplicate.freeze
30
+ when String
31
+ value.dup.freeze
32
+ else
33
+ value
34
+ end
35
+ end
36
+ end
37
+ private_constant :ModelValue
38
+
39
+ Label = Data.define(:kind, :value) do
40
+ KINDS = %i[text symbols epsilon uml].freeze
41
+
42
+ def initialize(kind:, value: nil)
43
+ resolved_kind = kind.to_sym
44
+ raise ArgumentError, "Unknown label kind: #{kind.inspect}" unless KINDS.include?(resolved_kind)
45
+
46
+ normalized_value = case resolved_kind
47
+ when :text
48
+ value.to_s
49
+ when :symbols
50
+ symbols = Array(value).map(&:to_s)
51
+ raise ArgumentError, 'Symbol label requires at least one symbol' if symbols.empty?
52
+
53
+ symbols
54
+ when :epsilon
55
+ value.nil? ? Graphomaton::DEFAULT_EPSILON_LABEL : value.to_s
56
+ when :uml
57
+ raise ArgumentError, 'UML label value must be a Hash' unless value.is_a?(Hash)
58
+
59
+ uml_value = {
60
+ event: value.key?(:event) ? value[:event] : value['event'],
61
+ guard: value.key?(:guard) ? value[:guard] : value['guard'],
62
+ action: value.key?(:action) ? value[:action] : value['action']
63
+ }.compact
64
+ raise ArgumentError, 'UML label requires an event' unless uml_value.key?(:event)
65
+
66
+ uml_value
67
+ end
68
+ super(kind: resolved_kind, value: ModelValue.copy(normalized_value))
69
+ end
70
+
71
+ def self.text(value)
72
+ new(kind: :text, value: value)
73
+ end
74
+
75
+ def self.symbols(*values)
76
+ new(kind: :symbols, value: values.flatten)
77
+ end
78
+
79
+ def self.epsilon(display = Graphomaton::DEFAULT_EPSILON_LABEL)
80
+ new(kind: :epsilon, value: display)
81
+ end
82
+
83
+ def self.uml(event:, guard: nil, action: nil)
84
+ new(kind: :uml, value: { event: event, guard: guard, action: action }.compact)
85
+ end
86
+
87
+ def to_s
88
+ case kind
89
+ when :symbols
90
+ value.join(', ')
91
+ when :epsilon
92
+ value
93
+ when :uml
94
+ event = value.fetch(:event).to_s
95
+ guard = value[:guard] ? " [#{value[:guard]}]" : ''
96
+ action = value[:action] ? " / #{value[:action]}" : ''
97
+ "#{event}#{guard}#{action}"
98
+ else
99
+ value.to_s
100
+ end
101
+ end
102
+
103
+ def to_h
104
+ { type: kind, value: value }.compact
105
+ end
106
+ end
107
+
108
+ State = Data.define(:id, :x, :y, :label, :style, :metadata, :shape, :kind) do
109
+ MISSING = Object.new.freeze
110
+
111
+ def initialize(id:, x:, y:, label:, style:, metadata:, shape:, kind:)
112
+ super(
113
+ id: ModelValue.copy(id),
114
+ x: x,
115
+ y: y,
116
+ label: ModelValue.copy(label),
117
+ style: ModelValue.copy(style),
118
+ metadata: ModelValue.copy(metadata),
119
+ shape: ModelValue.copy(shape),
120
+ kind: kind.nil? ? nil : kind.to_sym
121
+ )
122
+ end
123
+
124
+ def [](key)
125
+ return id if key == :name || key == :id
126
+
127
+ public_send(key)
128
+ rescue NoMethodError
129
+ nil
130
+ end
131
+
132
+ def fetch(key, default = MISSING)
133
+ value = self[key]
134
+ return value unless value.nil?
135
+ return default unless default.equal?(MISSING)
136
+
137
+ raise KeyError, "key not found: #{key.inspect}"
138
+ end
139
+
140
+ def to_h
141
+ output = { name: id, x: x, y: y }
142
+ output[:label] = label unless label.nil?
143
+ output[:style] = style unless style.nil?
144
+ output[:metadata] = metadata unless metadata.nil?
145
+ output[:shape] = shape unless shape.nil?
146
+ output[:kind] = kind unless kind.nil?
147
+ output
148
+ end
149
+ end
150
+
151
+ Transition = Data.define(:id, :from, :to, :label, :style, :metadata, :line_style) do
152
+ def initialize(id:, from:, to:, label:, style:, metadata:, line_style:)
153
+ super(
154
+ id: id,
155
+ from: ModelValue.copy(from),
156
+ to: ModelValue.copy(to),
157
+ label: ModelValue.copy(label),
158
+ style: ModelValue.copy(style),
159
+ metadata: ModelValue.copy(metadata),
160
+ line_style: ModelValue.copy(line_style)
161
+ )
162
+ end
163
+
164
+ def [](key)
165
+ public_send(key)
166
+ rescue NoMethodError
167
+ nil
168
+ end
169
+
170
+ def to_h
171
+ output = { from: from, to: to, label: label.is_a?(Label) ? label.to_s : label }
172
+ output[:style] = style unless style.nil?
173
+ output[:metadata] = metadata unless metadata.nil?
174
+ output[:line_style] = line_style unless line_style.nil?
175
+ output
176
+ end
177
+ end
178
+
179
+ Diagnostic = Data.define(:code, :severity, :path, :message, :hint) do
180
+ def initialize(code:, severity:, path:, message:, hint: nil)
181
+ super(
182
+ code: ModelValue.copy(code.to_s),
183
+ severity: severity.to_sym,
184
+ path: ModelValue.copy(Array(path)),
185
+ message: ModelValue.copy(message.to_s),
186
+ hint: hint.nil? ? nil : ModelValue.copy(hint.to_s)
187
+ )
188
+ end
189
+
190
+ def to_h
191
+ { code: code, severity: severity, path: path, message: message, hint: hint }.compact
192
+ end
193
+ end
194
+
195
+
196
+ RenderOptions = Data.define(:format, :width, :height, :options) do
197
+ def initialize(format: :svg, width: 800, height: 600, options: {})
198
+ super(format: format.to_sym, width: width, height: height, options: ModelValue.copy(options))
199
+ end
200
+ end
201
+
202
+ SvgOptions = Data.define(:width, :height, :options) do
203
+ def initialize(width: 800, height: 600, **options)
204
+ super(width: width, height: height, options: ModelValue.copy(options))
205
+ end
206
+ end
207
+
208
+ RenderResult = Data.define(:output, :diagnostics, :bounds, :layout) do
209
+ def initialize(output:, diagnostics:, bounds:, layout:)
210
+ super(
211
+ output: ModelValue.copy(output),
212
+ diagnostics: ModelValue.copy(Array(diagnostics)),
213
+ bounds: ModelValue.copy(bounds),
214
+ layout: ModelValue.copy(layout)
215
+ )
216
+ end
217
+ end
218
+ end