graphomaton 0.1.1 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -3
  3. data/README.md +454 -27
  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 +284 -0
  20. data/lib/graphomaton/exporters/mermaid.rb +774 -0
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +284 -0
  23. data/lib/graphomaton/exporters/png.rb +172 -0
  24. data/lib/graphomaton/exporters/svg.rb +2872 -0
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +13 -0
  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 +2865 -240
  35. data/sig/graphomaton.rbs +127 -0
  36. metadata +39 -17
  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 -19
  42. data/sample/complex.rb +0 -24
  43. data/sample/nfa.rb +0 -19
  44. data/spec/graphomaton_spec.rb +0 -371
  45. data/spec/spec_helper.rb +0 -13
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ class Error < StandardError; end
5
+ class ParseError < Error; end
6
+ class ValidationError < Error; end
7
+ class LayoutError < Error; end
8
+ class ExportError < Error; end
9
+ class ConversionError < ExportError; end
10
+ class SecurityError < Error; end
11
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ class ExporterRegistry
5
+ Entry = Data.define(:name, :aliases, :extensions, :binary, :capabilities, :loader) do
6
+ def exporter
7
+ resolved = loader.call
8
+ return resolved if resolved.respond_to?(:new)
9
+
10
+ raise ArgumentError, "Exporter #{name.inspect} loader must return a class"
11
+ end
12
+ end
13
+
14
+ def initialize
15
+ @entries = {}
16
+ @aliases = {}
17
+ end
18
+
19
+ def register(name, aliases: [], extensions: [], binary: false, capabilities: [], exporter: nil, &loader)
20
+ canonical = name.to_sym
21
+ resolved_loader = loader || (-> { exporter })
22
+ raise ArgumentError, "Exporter #{canonical.inspect} requires an exporter class or loader block" unless exporter || loader
23
+
24
+ keys = [canonical] + aliases.map(&:to_sym) + extensions.map { |extension| extension.to_s.delete_prefix('.').downcase.to_sym }
25
+ collisions = keys.select { |key| @aliases.key?(key) }.uniq
26
+ unless collisions.empty?
27
+ raise ArgumentError, "Exporter identifiers are already registered: #{collisions.join(', ')}"
28
+ end
29
+
30
+ entry = Entry.new(
31
+ name: canonical,
32
+ aliases: aliases.map(&:to_sym).freeze,
33
+ extensions: extensions.map { |extension| extension.to_s.delete_prefix('.').downcase }.freeze,
34
+ binary: binary,
35
+ capabilities: capabilities.map(&:to_sym).freeze,
36
+ loader: resolved_loader
37
+ )
38
+ @entries[canonical] = entry
39
+ ([canonical] + entry.aliases + entry.extensions.map(&:to_sym)).each { |key| @aliases[key] = canonical }
40
+ entry
41
+ end
42
+
43
+ def unregister(format)
44
+ canonical = resolve(format)
45
+ entry = @entries.delete(canonical)
46
+ @aliases.delete_if { |_key, value| value == canonical }
47
+ entry
48
+ end
49
+
50
+ def resolve(format)
51
+ key = format.to_s.delete_prefix('.').downcase.to_sym
52
+ canonical = @aliases[key]
53
+ return canonical if canonical
54
+
55
+ raise ArgumentError, "Unknown format: #{format.inspect}. Available formats: #{formats.join(', ')}"
56
+ end
57
+
58
+ def fetch(format)
59
+ @entries.fetch(resolve(format))
60
+ end
61
+
62
+ def formats
63
+ @entries.keys.freeze
64
+ end
65
+
66
+ def aliases
67
+ @aliases.dup.freeze
68
+ end
69
+ end
70
+
71
+ module ExporterCapabilities
72
+ FEATURE_DETECTORS = {
73
+ state_style: ->(graph) { graph.state_records.any? { |_, state| state.style } },
74
+ transition_style: ->(graph) { graph.transition_records.any? { |transition| transition.style } },
75
+ url: lambda { |graph|
76
+ graph.state_records.any? { |_, state| metadata_value(state.metadata, :url, :href) } ||
77
+ graph.transition_records.any? { |transition| metadata_value(transition.metadata, :url, :href) }
78
+ },
79
+ tooltip: lambda { |graph|
80
+ graph.state_records.any? { |_, state| metadata_value(state.metadata, :tooltip, :description) } ||
81
+ graph.transition_records.any? { |transition| metadata_value(transition.metadata, :tooltip, :description) }
82
+ },
83
+ group: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :group, :cluster) } },
84
+ parent: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :parent) } },
85
+ pseudostate: lambda { |graph|
86
+ graph.state_records.any? do |_, state|
87
+ (state.kind && state.kind != :normal) || metadata_value(state.metadata, :kind, :type)
88
+ end
89
+ },
90
+ bundle: ->(graph) { graph.transition_records.any? { |transition| metadata_value(transition.metadata, :bundle) } },
91
+ line_style: ->(graph) { graph.transition_records.any?(&:line_style) }
92
+ }.freeze
93
+
94
+ def self.losses_for(graph, capabilities)
95
+ FEATURE_DETECTORS.filter_map do |feature, detector|
96
+ feature if detector.call(graph) && !capabilities.include?(feature)
97
+ end
98
+ end
99
+
100
+ def self.metadata_value(metadata, *keys)
101
+ return nil unless metadata.is_a?(Hash)
102
+
103
+ keys.each do |key|
104
+ value = metadata[key] || metadata[key.to_s]
105
+ return value if value
106
+ end
107
+ nil
108
+ end
109
+ private_class_method :metadata_value
110
+ end
111
+
112
+ module ExporterIntrospection
113
+ def capabilities
114
+ Graphomaton.exporter_capabilities(exporter_format)
115
+ end
116
+
117
+ def losses_for(graph = @automaton)
118
+ Graphomaton::ExporterCapabilities.losses_for(graph, capabilities)
119
+ end
120
+
121
+ private
122
+
123
+ def exporter_format
124
+ self.class.name.split('::').last.downcase.to_sym
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ module Exporters
5
+ class Dot
6
+ include Graphomaton::ExporterIntrospection
7
+ DEFAULT_DIRECTION = :lr
8
+ DEFAULT_RANK_CONSTRAINTS = false
9
+ DIRECTION_OPTIONS = %i[lr tb rl bt].freeze
10
+ LINE_STYLE_OPTIONS = %i[solid dashed dotted].freeze
11
+ PSEUDOSTATE_SHAPES = {
12
+ choice: 'diamond',
13
+ fork: 'point',
14
+ join: 'point'
15
+ }.freeze
16
+
17
+ def initialize(automaton, direction: DEFAULT_DIRECTION, theme: nil, rank_constraints: DEFAULT_RANK_CONSTRAINTS)
18
+ @automaton = automaton
19
+ @direction = resolve_direction(direction)
20
+ @theme = resolve_theme(theme)
21
+ @rank_constraints = rank_constraints
22
+ @identifiers = IdentifierAllocator.new
23
+ @state_names = @automaton.state_records.each_key.to_h do |name|
24
+ [name, @identifiers.allocate([:state, name], preferred: name, prefix: 'state')]
25
+ end
26
+ @start_name = @identifiers.allocate(:start, preferred: '__start__', prefix: '__graphomaton_start')
27
+ end
28
+
29
+ def export
30
+ lines = ['digraph finite_state_machine {']
31
+ lines << " rankdir=#{rankdir};"
32
+ lines << " graph [bgcolor=\"#{escape_label(@theme[:background])}\"];" if @theme && @theme[:background]
33
+ lines << " node [#{node_attributes('circle')}];"
34
+ lines << ''
35
+ state_attributes = state_attribute_lines
36
+ lines.concat(state_attributes)
37
+ lines << '' if state_attributes.any?
38
+ state_clusters = state_cluster_lines
39
+ lines.concat(state_clusters)
40
+ lines << '' if state_clusters.any?
41
+
42
+ if @automaton.initial_state
43
+ lines << " #{quoted_id(@start_name)} [shape=point];"
44
+ lines << " #{quoted_id(@start_name)} -> #{quoted_state_id(@automaton.initial_state)};"
45
+ lines << ''
46
+ end
47
+
48
+ rank_constraints = rank_constraint_lines
49
+ lines.concat(rank_constraints)
50
+ lines << '' if rank_constraints.any?
51
+
52
+ @automaton.transition_records.each do |trans|
53
+ lines << " #{quoted_state_id(trans[:from])} -> #{quoted_state_id(trans[:to])} [#{edge_attributes(trans)}];"
54
+ end
55
+
56
+ lines << '}'
57
+ "#{lines.join("\n")}\n"
58
+ end
59
+
60
+ private
61
+
62
+ def resolve_direction(direction)
63
+ resolved = direction.to_sym
64
+ return resolved if DIRECTION_OPTIONS.include?(resolved)
65
+
66
+ raise ArgumentError, "Unknown direction: #{direction.inspect}. Available directions: #{DIRECTION_OPTIONS.join(', ')}"
67
+ end
68
+
69
+ def resolve_theme(theme)
70
+ return nil unless theme
71
+
72
+ Graphomaton::Theme.resolve(theme, context: 'DOT theme')
73
+ end
74
+
75
+ def node_attributes(shape)
76
+ return "shape = #{shape}" unless @theme
77
+
78
+ [
79
+ "shape = #{shape}",
80
+ 'style=filled',
81
+ "fillcolor=\"#{escape_label(@theme[:state_fill])}\"",
82
+ "color=\"#{escape_label(@theme[:stroke])}\"",
83
+ "fontcolor=\"#{escape_label(@theme[:state_text])}\""
84
+ ].join(', ')
85
+ end
86
+
87
+ def edge_attributes(transition)
88
+ attributes = ["label=\"#{escape_label(transition[:label])}\""]
89
+ add_metadata_attributes(attributes, transition[:metadata])
90
+ add_bundle_attribute(attributes, transition[:metadata])
91
+ add_line_style_attributes(attributes, transition[:line_style])
92
+ if @theme
93
+ attributes << "color=\"#{escape_label(@theme[:stroke])}\""
94
+ attributes << "fontcolor=\"#{escape_label(@theme[:transition_label])}\""
95
+ end
96
+ attributes.join(', ')
97
+ end
98
+
99
+ def state_attribute_lines
100
+ @state_attribute_lines ||= @automaton.state_records.map do |name, state|
101
+ attributes = state_attributes(name, state)
102
+ suffix = attributes.empty? ? '' : " [#{attributes.join(', ')}]"
103
+ " #{quoted_state_id(name)}#{suffix};"
104
+ end
105
+ end
106
+
107
+ def state_attributes(name, state)
108
+ attributes = []
109
+ label = state[:label]
110
+ identifier = state_name(name)
111
+ pseudostate_shape = @automaton.final_states.include?(name) ? 'doublecircle' : pseudostate_shape(state)
112
+ attributes << "shape=\"#{pseudostate_shape}\"" if pseudostate_shape
113
+ attributes << "label=\"#{escape_label(label || name)}\"" unless label.nil? && identifier == name.to_s
114
+ add_metadata_attributes(attributes, state[:metadata])
115
+ attributes
116
+ end
117
+
118
+ def pseudostate_shape(state)
119
+ type = pseudostate_type(state)
120
+ return nil unless type
121
+
122
+ PSEUDOSTATE_SHAPES.fetch(type)
123
+ end
124
+
125
+ def pseudostate_type(state)
126
+ type = dot_metadata_value(state, :shape) ||
127
+ dot_metadata_value(state, :type) ||
128
+ dot_metadata_value(state, :kind) ||
129
+ state[:kind] ||
130
+ state_metadata_value(state, :kind) ||
131
+ state_metadata_value(state, :dot_shape) ||
132
+ state_metadata_value(state, :dot_type)
133
+ normalized = type.to_s.tr('-', '_').to_sym
134
+
135
+ PSEUDOSTATE_SHAPES.key?(normalized) ? normalized : nil
136
+ end
137
+
138
+ def dot_metadata_value(state, key)
139
+ metadata = state[:metadata]
140
+ return nil unless metadata.is_a?(Hash)
141
+
142
+ dot = metadata[:dot] || metadata['dot']
143
+ return nil unless dot.is_a?(Hash)
144
+
145
+ dot[key] || dot[key.to_s]
146
+ end
147
+
148
+ def state_metadata_value(state, key)
149
+ metadata = state[:metadata]
150
+ return nil unless metadata.is_a?(Hash)
151
+
152
+ metadata[key] || metadata[key.to_s]
153
+ end
154
+
155
+ def state_cluster_lines
156
+ clusters = @automaton.state_records.each_with_object({}) do |(name, state), groups|
157
+ group = state_group_name(state)
158
+ next unless group
159
+
160
+ groups[group] ||= []
161
+ groups[group] << name
162
+ end
163
+ return [] if clusters.empty?
164
+
165
+ clusters.flat_map do |group, states|
166
+ [
167
+ " subgraph #{quoted_id(cluster_name(group))} {",
168
+ " label=\"#{escape_label(group)}\";",
169
+ *states.map { |state| " #{quoted_state_id(state)};" },
170
+ ' }'
171
+ ]
172
+ end
173
+ end
174
+
175
+ def state_group_name(state)
176
+ metadata = state[:metadata]
177
+ return nil unless metadata.is_a?(Hash)
178
+
179
+ metadata_value(metadata, :group, :cluster)
180
+ end
181
+
182
+ def add_metadata_attributes(attributes, metadata)
183
+ return unless metadata.is_a?(Hash)
184
+
185
+ url = metadata_value(metadata, :url, :href)
186
+ tooltip = metadata_value(metadata, :tooltip, :description)
187
+
188
+ if url
189
+ safe_url = UrlPolicy.validate(url, context: 'DOT metadata URL')
190
+ attributes << "URL=\"#{escape_label(safe_url)}\""
191
+ end
192
+ attributes << "tooltip=\"#{escape_label(tooltip)}\"" if tooltip
193
+ end
194
+
195
+ def add_bundle_attribute(attributes, metadata)
196
+ return unless metadata.is_a?(Hash)
197
+
198
+ bundle = metadata_value(metadata, :bundle)
199
+ return unless bundle
200
+
201
+ attributes << "class=\"#{dot_class_name("bundle-#{bundle}")}\""
202
+ end
203
+
204
+ def add_line_style_attributes(attributes, line_style)
205
+ return unless line_style
206
+
207
+ resolved = line_style.to_sym
208
+ unless LINE_STYLE_OPTIONS.include?(resolved)
209
+ raise ArgumentError, "Unknown DOT transition line_style: #{line_style.inspect}. Available values: #{LINE_STYLE_OPTIONS.join(', ')}"
210
+ end
211
+
212
+ attributes << "style=\"#{resolved}\"" unless resolved == :solid
213
+ end
214
+
215
+ def metadata_value(metadata, *keys)
216
+ keys.each do |key|
217
+ value = metadata[key] || metadata[key.to_s]
218
+ return value if value
219
+ end
220
+
221
+ nil
222
+ end
223
+
224
+ def rank_constraint_lines
225
+ return [] unless @rank_constraints
226
+
227
+ lines = []
228
+ if @automaton.initial_state
229
+ lines << " { rank=source; #{quoted_state_id(@automaton.initial_state)}; }"
230
+ end
231
+
232
+ unless @automaton.final_states.empty?
233
+ final_states = @automaton.final_states.map { |state| "#{quoted_state_id(state)};" }.join(' ')
234
+ lines << " { rank=sink; #{final_states} }"
235
+ end
236
+
237
+ lines
238
+ end
239
+
240
+ def rankdir
241
+ case @direction
242
+ when :tb
243
+ 'TB'
244
+ when :bt
245
+ 'BT'
246
+ when :rl
247
+ 'RL'
248
+ else
249
+ 'LR'
250
+ end
251
+ end
252
+
253
+ def escape_label(label)
254
+ label.to_s
255
+ .gsub('\\') { '\\\\' }
256
+ .gsub('"') { '\\"' }
257
+ .gsub(/\r\n?|\n/) { '\\n' }
258
+ end
259
+
260
+ def state_name(name)
261
+ @state_names.fetch(name) do
262
+ @identifiers.allocate([:external_state, name], preferred: name, prefix: 'state')
263
+ end
264
+ end
265
+
266
+ def quoted_state_id(name)
267
+ quoted_id(state_name(name))
268
+ end
269
+
270
+ def quoted_id(identifier)
271
+ "\"#{escape_label(identifier)}\""
272
+ end
273
+
274
+ def cluster_name(group)
275
+ identifier = @identifiers.allocate([:group, group], preferred: group, prefix: 'group')
276
+ "cluster_#{identifier}"
277
+ end
278
+
279
+ def dot_class_name(value)
280
+ value.to_s.gsub(/[^A-Za-z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')
281
+ end
282
+ end
283
+ end
284
+ end