automograph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d2a137a5c9fb5e32a1229fbe58dd1eb0143cd1dbeec612c54962b0a28b98276d
4
+ data.tar.gz: 4182ed787561105dbeaefe75f2096c752ea3f2cc3a59d98b254462c0af7c1b1a
5
+ SHA512:
6
+ metadata.gz: 3a0aa78d72ceb0e484d1dbc07240d55077528deace5f69f1132a0994db3208832c71808665f480c5907ef1d372f16044b0c7eb07b7586f5e33a3bba1624da9e8
7
+ data.tar.gz: 3f0fdf171417b00408dd3ce24d553f7fcc2be99b2115ce3f6a0b97b506949ebf89129f6c6072c3f27b9537a5da1f72c832c44c6f0bc79f752e8a332959f95837
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # Automograph
2
+
3
+ A pure Ruby library for visualizing automata and state machines. Supports multiple output formats including Mermaid, GraphViz DOT, PlantUML, and interactive HTML.
4
+
5
+ ## Features
6
+
7
+ - Runs as Pure Ruby code with no external dependencies.
8
+ - Exports to multiple formats such as HTML, Mermaid, GraphViz DOT, and PlantUML.
9
+ - Adopts a generic model to work with any automaton or state machine, not just Lrama.
10
+ - Produces interactive HTML for browsing states with click-to-view details.
11
+ - Remains customizable to let you control layout, styling, and filtering.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'automograph'
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```bash
24
+ gem install automograph
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Basic Example
30
+
31
+ ```ruby
32
+ require 'automograph'
33
+
34
+ # Create a model
35
+ model = Automograph::Model.new(name: "Simple DFA")
36
+ model.add_state(0, label: "Start", initial: true)
37
+ model.add_state(1, label: "Middle")
38
+ model.add_state(2, label: "Accept", accept: true)
39
+ model.add_transition(from: 0, to: 1, label: "a")
40
+ model.add_transition(from: 1, to: 2, label: "b")
41
+ model.initial_state_id = 0
42
+
43
+ # Render to HTML
44
+ html = Automograph.render(model, format: :html)
45
+ File.write("diagram.html", html)
46
+
47
+ # Or use render_to_file
48
+ Automograph.render_to_file(model, "diagram.html") # Auto-detects format from extension
49
+ Automograph.render_to_file(model, "diagram.mmd") # Mermaid
50
+ Automograph.render_to_file(model, "diagram.dot") # GraphViz
51
+ Automograph.render_to_file(model, "diagram.puml") # PlantUML
52
+ ```
53
+
54
+ ### From JSON
55
+
56
+ ```ruby
57
+ json = {
58
+ name: "LALR(1) Automaton",
59
+ states: [
60
+ { id: 0, label: "State 0", initial: true, description: "$accept → • S $end" },
61
+ { id: 1, label: "State 1", description: "S → A • B" },
62
+ { id: 2, label: "State 2", accept: true, description: "S → A B •" }
63
+ ],
64
+ transitions: [
65
+ { from: 0, to: 1, label: "A", type: "shift" },
66
+ { from: 1, to: 2, label: "B", type: "shift" }
67
+ ]
68
+ }
69
+
70
+ model = Automograph::Model.from_hash(json)
71
+ Automograph.render_to_file(model, "output.html")
72
+ ```
73
+
74
+ ### With Options
75
+
76
+ ```ruby
77
+ Automograph.render(model,
78
+ format: :html,
79
+ direction: :TB, # Top to Bottom (default: :LR)
80
+ show_description: true, # Show state descriptions (default: true)
81
+ highlight_accept: true, # Highlight accept states (default: true)
82
+ highlight_initial: true, # Highlight initial state (default: true)
83
+ max_states: 50, # Limit number of states displayed
84
+ title: "My Automaton" # Custom title
85
+ )
86
+ ```
87
+
88
+ ## Output Formats
89
+
90
+ - HTML (Mermaid Embedded)
91
+ - Mermaid
92
+ - GraphViz DOT
93
+ - PlantUML
94
+
95
+ ## License
96
+
97
+ MIT License
98
+
99
+ ## Contributing
100
+
101
+ Bug reports and pull requests are welcome on GitHub.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Automograph
4
+ module Formatter
5
+ # Registry for formatters
6
+ @formatters = {}
7
+
8
+ # Register a formatter
9
+ #
10
+ # @param name [Symbol] Formatter name
11
+ # @param klass [Class] Formatter class
12
+ def self.register(name, klass)
13
+ @formatters[name.to_sym] = klass
14
+ end
15
+
16
+ # Get a formatter by name
17
+ #
18
+ # @param name [Symbol] Formatter name
19
+ # @return [Class] Formatter class
20
+ # @raise [ArgumentError] If formatter not found
21
+ def self.for(name)
22
+ @formatters[name.to_sym] or raise ArgumentError, "Unknown format: #{name}. Available formats: #{@formatters.keys.join(', ')}"
23
+ end
24
+
25
+ # Base class for all formatters
26
+ class Base
27
+ attr_reader :model, :options
28
+
29
+ def initialize(model, **options)
30
+ @model = model
31
+ @options = Options.from_hash(options)
32
+ end
33
+
34
+ # Render the model to a string
35
+ #
36
+ # @return [String] Rendered output
37
+ # @raise [NotImplementedError] Must be implemented by subclasses
38
+ def render
39
+ raise NotImplementedError, "#{self.class}#render must be implemented"
40
+ end
41
+
42
+ protected
43
+
44
+ # Get filtered states based on options
45
+ #
46
+ # @return [Array<State>] Filtered states
47
+ def filtered_states
48
+ states = @model.states.values
49
+ states = states.select(&@options.state_filter) if @options.state_filter
50
+ states = states.first(@options.max_states) if @options.max_states
51
+ states
52
+ end
53
+
54
+ # Get filtered transitions based on filtered states
55
+ #
56
+ # @return [Array<Transition>] Filtered transitions
57
+ def filtered_transitions
58
+ state_ids = filtered_states.map(&:id)
59
+ @model.transitions.select { |t| state_ids.include?(t.from) && state_ids.include?(t.to) }
60
+ end
61
+
62
+ # Escape special characters in labels
63
+ #
64
+ # @param text [String] Text to escape
65
+ # @return [String] Escaped text
66
+ def escape_label(text)
67
+ text.to_s.gsub('"', '\\"').gsub("\n", "\\n")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Automograph
4
+ module Formatter
5
+ # GraphViz DOT formatter
6
+ class Graphviz < Base
7
+ def render
8
+ lines = []
9
+ lines << "digraph #{sanitize_id(@model.name)} {"
10
+ lines << " rankdir=#{@options.direction};"
11
+ lines << " node [shape=box, style=rounded];"
12
+ lines << ""
13
+
14
+ # Initial state marker
15
+ if @model.initial_state
16
+ lines << ' __start__ [shape=point, width=0.2];'
17
+ lines << " __start__ -> State#{@model.initial_state_id};"
18
+ lines << ""
19
+ end
20
+
21
+ # State definitions
22
+ filtered_states.each do |state|
23
+ attrs = state_attributes(state)
24
+ lines << " State#{state.id} [#{attrs}];"
25
+ end
26
+ lines << ""
27
+
28
+ # Transitions
29
+ filtered_transitions.each do |t|
30
+ style = transition_style(t)
31
+ lines << " State#{t.from} -> State#{t.to} [label=\"#{escape_label(t.label)}\"#{style}];"
32
+ end
33
+
34
+ # Accept state markers
35
+ @model.accept_states.each do |state|
36
+ next unless filtered_states.include?(state)
37
+
38
+ lines << ""
39
+ lines << " __accept_#{state.id}__ [shape=doublecircle, width=0.3, label=\"\"];"
40
+ lines << " State#{state.id} -> __accept_#{state.id}__;"
41
+ end
42
+
43
+ lines << "}"
44
+ lines.join("\n")
45
+ end
46
+
47
+ private
48
+
49
+ def sanitize_id(name)
50
+ name.gsub(/[^a-zA-Z0-9_]/, '_')
51
+ end
52
+
53
+ def state_attributes(state)
54
+ attrs = []
55
+ label_parts = [state.label]
56
+ if @options.show_description && state.description
57
+ label_parts << "---"
58
+ label_parts << state.description
59
+ end
60
+ attrs << "label=\"#{escape_label(label_parts.join("\\n"))}\""
61
+
62
+ if state.accept? && @options.highlight_accept
63
+ attrs << "shape=doublebox"
64
+ attrs << "style=\"rounded,bold\""
65
+ end
66
+
67
+ if state.initial? && @options.highlight_initial
68
+ attrs << "penwidth=2"
69
+ end
70
+
71
+ attrs.join(", ")
72
+ end
73
+
74
+ def transition_style(t)
75
+ case t.type
76
+ when :goto then ", style=dashed"
77
+ when :reduce then ", style=dotted, color=gray"
78
+ else ""
79
+ end
80
+ end
81
+ end
82
+
83
+ register :graphviz, Graphviz
84
+ register :dot, Graphviz # Alias
85
+ end
86
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Automograph
6
+ module Formatter
7
+ # HTML formatter with embedded Mermaid diagram
8
+ class Html < Base
9
+ TEMPLATE = <<~'HTML'
10
+ <!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
+ <title><%= @model.name %></title>
16
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
17
+ <style>
18
+ * { margin: 0; padding: 0; box-sizing: border-box; }
19
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
20
+ .header { background: #2d3748; color: white; padding: 1rem 2rem; }
21
+ .header h1 { font-size: 1.25rem; font-weight: 500; }
22
+ .stats { font-size: 0.875rem; opacity: 0.8; margin-top: 0.5rem; }
23
+ .controls { background: white; padding: 0.75rem 2rem; border-bottom: 1px solid #e2e8f0; }
24
+ .controls label { margin-right: 1rem; font-size: 0.875rem; }
25
+ .diagram-container { padding: 2rem; overflow: auto; height: calc(100vh - 120px); }
26
+ .mermaid { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
27
+ .side-panel {
28
+ position: fixed;
29
+ right: -400px;
30
+ top: 0;
31
+ width: 400px;
32
+ height: 100vh;
33
+ background: white;
34
+ box-shadow: -2px 0 10px rgba(0,0,0,0.1);
35
+ transition: right 0.3s;
36
+ z-index: 100;
37
+ }
38
+ .side-panel.open { right: 0; }
39
+ .panel-header {
40
+ padding: 1rem;
41
+ background: #2d3748;
42
+ color: white;
43
+ display: flex;
44
+ justify-content: space-between;
45
+ }
46
+ .panel-content {
47
+ padding: 1rem;
48
+ overflow-y: auto;
49
+ max-height: calc(100vh - 60px);
50
+ }
51
+ .panel-content pre {
52
+ background: #f7fafc;
53
+ padding: 1rem;
54
+ border-radius: 4px;
55
+ font-size: 0.875rem;
56
+ white-space: pre-wrap;
57
+ }
58
+ .close-btn {
59
+ background: none;
60
+ border: none;
61
+ color: white;
62
+ font-size: 1.5rem;
63
+ cursor: pointer;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <div class="header">
69
+ <h1><%= @model.name %></h1>
70
+ <div class="stats">
71
+ States: <%= @model.states.size %> |
72
+ Transitions: <%= @model.transitions.size %> |
73
+ Accept States: <%= @model.accept_states.size %>
74
+ </div>
75
+ </div>
76
+ <div class="controls">
77
+ <label>
78
+ <input type="checkbox" id="showDesc" <%= @options.show_description ? 'checked' : '' %>>
79
+ Show Descriptions
80
+ </label>
81
+ <input type="text" placeholder="Search state..." id="search" style="padding: 0.25rem 0.5rem;">
82
+ </div>
83
+ <div class="diagram-container">
84
+ <pre class="mermaid">
85
+ <%= mermaid_content %>
86
+ </pre>
87
+ </div>
88
+ <div class="side-panel" id="panel">
89
+ <div class="panel-header">
90
+ <span id="panelTitle">State Details</span>
91
+ <button class="close-btn" onclick="closePanel()">&times;</button>
92
+ </div>
93
+ <div class="panel-content">
94
+ <pre id="panelContent"></pre>
95
+ </div>
96
+ </div>
97
+ <script>
98
+ mermaid.initialize({ startOnLoad: true, theme: 'neutral', securityLevel: 'loose' });
99
+ const stateData = <%= state_data_json %>;
100
+ document.querySelector('.mermaid').addEventListener('click', e => {
101
+ const node = e.target.closest('.node');
102
+ if (node) {
103
+ const id = node.id.replace('state-', '').replace('State', '');
104
+ showState(id);
105
+ }
106
+ });
107
+ function showState(id) {
108
+ const state = stateData[id];
109
+ if (!state) return;
110
+ document.getElementById('panelTitle').textContent = state.label;
111
+ document.getElementById('panelContent').textContent = state.description || 'No description';
112
+ document.getElementById('panel').classList.add('open');
113
+ }
114
+ function closePanel() {
115
+ document.getElementById('panel').classList.remove('open');
116
+ }
117
+ document.getElementById('search').addEventListener('input', e => {
118
+ const q = e.target.value.toLowerCase();
119
+ document.querySelectorAll('.node').forEach(n => {
120
+ n.style.opacity = n.textContent.toLowerCase().includes(q) ? '1' : '0.3';
121
+ });
122
+ });
123
+ </script>
124
+ </body>
125
+ </html>
126
+ HTML
127
+
128
+ def render
129
+ ERB.new(TEMPLATE, trim_mode: '-').result(binding)
130
+ end
131
+
132
+ private
133
+
134
+ def mermaid_content
135
+ Mermaid.new(@model, **@options.to_h).render
136
+ end
137
+
138
+ def state_data_json
139
+ data = {}
140
+ @model.states.each do |id, state|
141
+ data[id] = { label: state.label, description: state.description }
142
+ end
143
+ JSON.generate(data)
144
+ end
145
+ end
146
+
147
+ register :html, Html
148
+ end
149
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Automograph
4
+ module Formatter
5
+ # Mermaid state diagram formatter
6
+ class Mermaid < Base
7
+ def render
8
+ lines = []
9
+ lines << "stateDiagram-v2"
10
+ lines << " direction #{@options.direction}"
11
+ lines << ""
12
+
13
+ # Initial state transition
14
+ if @model.initial_state
15
+ lines << " [*] --> State#{@model.initial_state_id}"
16
+ lines << ""
17
+ end
18
+
19
+ # State definitions
20
+ filtered_states.each do |state|
21
+ lines << state_definition(state)
22
+ lines << note_definition(state) if @options.show_description && state.description
23
+ lines << ""
24
+ end
25
+
26
+ # Transitions
27
+ filtered_transitions.each do |t|
28
+ lines << " State#{t.from} --> State#{t.to} : #{escape_label(t.label)}"
29
+ end
30
+
31
+ # Accept states to final
32
+ @model.accept_states.each do |state|
33
+ lines << " State#{state.id} --> [*]" if filtered_states.include?(state)
34
+ end
35
+
36
+ lines.join("\n")
37
+ end
38
+
39
+ private
40
+
41
+ def state_definition(state)
42
+ suffix = state.accept? ? " ✓" : ""
43
+ " State#{state.id}: #{state.label}#{suffix}"
44
+ end
45
+
46
+ def note_definition(state)
47
+ desc = state.description.gsub("\n", "\\n")
48
+ <<~NOTE.chomp
49
+ note right of State#{state.id}
50
+ #{desc}
51
+ end note
52
+ NOTE
53
+ end
54
+ end
55
+
56
+ register :mermaid, Mermaid
57
+ end
58
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Automograph
4
+ module Formatter
5
+ # PlantUML state diagram formatter
6
+ class Plantuml < Base
7
+ def render
8
+ lines = []
9
+ lines << "@startuml"
10
+ lines << "title #{@model.name}"
11
+ lines << "left to right direction" if @options.direction == :LR
12
+ lines << ""
13
+
14
+ # State definitions
15
+ filtered_states.each do |state|
16
+ lines << state_definition(state)
17
+ end
18
+ lines << ""
19
+
20
+ # Initial state
21
+ if @model.initial_state
22
+ lines << "[*] --> State#{@model.initial_state_id}"
23
+ end
24
+
25
+ # Transitions
26
+ filtered_transitions.each do |t|
27
+ arrow = transition_arrow(t)
28
+ lines << "State#{t.from} #{arrow} State#{t.to} : #{escape_label(t.label)}"
29
+ end
30
+
31
+ # Accept states
32
+ @model.accept_states.each do |state|
33
+ lines << "State#{state.id} --> [*]" if filtered_states.include?(state)
34
+ end
35
+
36
+ lines << ""
37
+ lines << "@enduml"
38
+ lines.join("\n")
39
+ end
40
+
41
+ private
42
+
43
+ def state_definition(state)
44
+ lines = []
45
+ lines << "state \"#{state.label}\" as State#{state.id}"
46
+
47
+ if @options.show_description && state.description
48
+ state.description.each_line do |line|
49
+ lines << "State#{state.id} : #{line.strip}"
50
+ end
51
+ end
52
+
53
+ lines.join("\n")
54
+ end
55
+
56
+ def transition_arrow(t)
57
+ case t.type
58
+ when :goto then "-->"
59
+ when :reduce then "-[dotted]->"
60
+ else "-->"
61
+ end
62
+ end
63
+ end
64
+
65
+ register :plantuml, Plantuml
66
+ register :puml, Plantuml # Alias
67
+ end
68
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Automograph
6
+ # Represents a generic automaton/state machine model
7
+ class Model
8
+ attr_reader :name, :states, :transitions, :metadata
9
+ attr_accessor :initial_state_id
10
+
11
+ def initialize(name: "Automaton")
12
+ @name = name
13
+ @states = {} # id => State
14
+ @transitions = [] # Array<Transition>
15
+ @metadata = {}
16
+ @initial_state_id = nil
17
+ end
18
+
19
+ # Add a state to the model
20
+ #
21
+ # @param id [Integer] State identifier
22
+ # @param label [String] Display label for the state
23
+ # @param description [String, nil] Optional description (e.g., LR items)
24
+ # @param accept [Boolean] Whether this is an accepting state
25
+ # @param initial [Boolean] Whether this is the initial state
26
+ # @param metadata [Hash] Additional metadata
27
+ # @return [State] The created state
28
+ def add_state(id, label: nil, description: nil, accept: false, initial: false, metadata: {})
29
+ @states[id] = State.new(
30
+ id: id,
31
+ label: label,
32
+ description: description,
33
+ accept: accept,
34
+ initial: initial,
35
+ metadata: metadata
36
+ )
37
+ end
38
+
39
+ # Add a transition between states
40
+ #
41
+ # @param from [Integer] Source state ID
42
+ # @param to [Integer] Target state ID
43
+ # @param label [String] Transition label (e.g., symbol name)
44
+ # @param type [Symbol, nil] Transition type (:shift, :goto, :reduce, etc.)
45
+ # @param metadata [Hash] Additional metadata
46
+ # @return [Transition] The created transition
47
+ def add_transition(from:, to:, label:, type: nil, metadata: {})
48
+ transition = Transition.new(
49
+ from: from,
50
+ to: to,
51
+ label: label,
52
+ type: type,
53
+ metadata: metadata
54
+ )
55
+ @transitions << transition
56
+ transition
57
+ end
58
+
59
+ # Get the initial state
60
+ #
61
+ # @return [State, nil] The initial state
62
+ def initial_state
63
+ @states[@initial_state_id]
64
+ end
65
+
66
+ # Get all accepting states
67
+ #
68
+ # @return [Array<State>] Array of accepting states
69
+ def accept_states
70
+ @states.values.select(&:accept?)
71
+ end
72
+
73
+ # Create a model from a hash
74
+ #
75
+ # @param hash [Hash] Hash representation of the model
76
+ # @return [Model] The created model
77
+ def self.from_hash(hash)
78
+ model = new(name: hash[:name] || hash["name"] || "Automaton")
79
+
80
+ (hash[:states] || hash["states"] || []).each do |s|
81
+ id = s[:id] || s["id"]
82
+ model.add_state(
83
+ id,
84
+ label: s[:label] || s["label"],
85
+ description: s[:description] || s["description"],
86
+ accept: s[:accept] || s["accept"] || false,
87
+ initial: s[:initial] || s["initial"] || false,
88
+ metadata: s[:metadata] || s["metadata"] || {}
89
+ )
90
+ model.initial_state_id = id if s[:initial] || s["initial"]
91
+ end
92
+
93
+ (hash[:transitions] || hash["transitions"] || []).each do |t|
94
+ model.add_transition(
95
+ from: t[:from] || t["from"],
96
+ to: t[:to] || t["to"],
97
+ label: t[:label] || t["label"],
98
+ type: (t[:type] || t["type"])&.to_sym,
99
+ metadata: t[:metadata] || t["metadata"] || {}
100
+ )
101
+ end
102
+
103
+ model
104
+ end
105
+
106
+ # Create a model from JSON
107
+ #
108
+ # @param json_string [String] JSON representation
109
+ # @return [Model] The created model
110
+ def self.from_json(json_string)
111
+ from_hash(JSON.parse(json_string, symbolize_names: true))
112
+ end
113
+
114
+ # Convert the model to a hash
115
+ #
116
+ # @return [Hash] Hash representation
117
+ def to_hash
118
+ {
119
+ name: @name,
120
+ states: @states.values.map(&:to_hash),
121
+ transitions: @transitions.map(&:to_hash),
122
+ metadata: @metadata
123
+ }
124
+ end
125
+
126
+ # Convert the model to JSON
127
+ #
128
+ # @return [String] JSON representation
129
+ def to_json(*_args)
130
+ JSON.pretty_generate(to_hash)
131
+ end
132
+ end
133
+
134
+ # Represents a state in the automaton
135
+ class State
136
+ attr_reader :id, :label, :description, :metadata
137
+ attr_accessor :accept, :initial
138
+
139
+ def initialize(id:, label: nil, description: nil, accept: false, initial: false, metadata: {})
140
+ @id = id
141
+ @label = label || "State #{id}"
142
+ @description = description
143
+ @accept = accept
144
+ @initial = initial
145
+ @metadata = metadata || {}
146
+ end
147
+
148
+ # Check if this is an accepting state
149
+ #
150
+ # @return [Boolean]
151
+ def accept?
152
+ @accept
153
+ end
154
+
155
+ # Check if this is the initial state
156
+ #
157
+ # @return [Boolean]
158
+ def initial?
159
+ @initial
160
+ end
161
+
162
+ # Convert the state to a hash
163
+ #
164
+ # @return [Hash]
165
+ def to_hash
166
+ {
167
+ id: @id,
168
+ label: @label,
169
+ description: @description,
170
+ accept: @accept,
171
+ initial: @initial,
172
+ metadata: @metadata
173
+ }
174
+ end
175
+ end
176
+
177
+ # Represents a transition between states
178
+ class Transition
179
+ attr_reader :from, :to, :label, :type, :metadata
180
+
181
+ def initialize(from:, to:, label:, type: nil, metadata: {})
182
+ @from = from
183
+ @to = to
184
+ @label = label
185
+ @type = type # :shift, :goto, :reduce, nil (generic)
186
+ @metadata = metadata || {}
187
+ end
188
+
189
+ # Convert the transition to a hash
190
+ #
191
+ # @return [Hash]
192
+ def to_hash
193
+ {
194
+ from: @from,
195
+ to: @to,
196
+ label: @label,
197
+ type: @type,
198
+ metadata: @metadata
199
+ }
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Automograph
4
+ # Options for diagram rendering
5
+ class Options
6
+ # Display options
7
+ attr_accessor :show_description # Show state descriptions (e.g., LR items)
8
+ attr_accessor :show_metadata # Show metadata
9
+ attr_accessor :highlight_accept # Highlight accepting states
10
+ attr_accessor :highlight_initial # Highlight initial state
11
+
12
+ # Layout
13
+ attr_accessor :direction # :LR, :TB, :RL, :BT
14
+
15
+ # Filtering
16
+ attr_accessor :state_filter # Proc to filter states
17
+ attr_accessor :max_states # Maximum number of states to display
18
+
19
+ # Style
20
+ attr_accessor :theme # :default, :dark, :minimal
21
+ attr_accessor :title # Diagram title
22
+
23
+ def initialize
24
+ @show_description = true
25
+ @show_metadata = false
26
+ @highlight_accept = true
27
+ @highlight_initial = true
28
+ @direction = :LR
29
+ @theme = :default
30
+ @state_filter = nil
31
+ @max_states = nil
32
+ @title = nil
33
+ end
34
+
35
+ # Create options from a hash
36
+ #
37
+ # @param hash [Hash] Option values
38
+ # @return [Options] The created options
39
+ def self.from_hash(hash)
40
+ opts = new
41
+ hash.each do |k, v|
42
+ setter = "#{k}="
43
+ opts.send(setter, v) if opts.respond_to?(setter)
44
+ end
45
+ opts
46
+ end
47
+
48
+ # Convert options to a hash
49
+ #
50
+ # @return [Hash] Hash representation
51
+ def to_h
52
+ {
53
+ show_description: @show_description,
54
+ show_metadata: @show_metadata,
55
+ highlight_accept: @highlight_accept,
56
+ highlight_initial: @highlight_initial,
57
+ direction: @direction,
58
+ state_filter: @state_filter,
59
+ max_states: @max_states,
60
+ theme: @theme,
61
+ title: @title
62
+ }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Automograph
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "automograph/version"
4
+ require_relative "automograph/model"
5
+ require_relative "automograph/options"
6
+ require_relative "automograph/formatter/base"
7
+ require_relative "automograph/formatter/mermaid"
8
+ require_relative "automograph/formatter/html"
9
+ require_relative "automograph/formatter/graphviz"
10
+ require_relative "automograph/formatter/plantuml"
11
+
12
+ # Automograph is a generic automaton/state machine visualization library
13
+ module Automograph
14
+ class Error < StandardError; end
15
+
16
+ # Render a model to a string
17
+ #
18
+ # @param model [Automograph::Model] The automaton model
19
+ # @param format [Symbol] Output format (:html, :mermaid, :graphviz, :plantuml)
20
+ # @param options [Hash] Rendering options
21
+ # @return [String] Rendered output
22
+ def self.render(model, format: :html, **options)
23
+ formatter = Formatter.for(format)
24
+ formatter.new(model, **options).render
25
+ end
26
+
27
+ # Render a model to a file
28
+ #
29
+ # @param model [Automograph::Model] The automaton model
30
+ # @param path [String] Output file path
31
+ # @param format [Symbol, nil] Output format (auto-detected from extension if nil)
32
+ # @param options [Hash] Rendering options
33
+ # @return [String] The output file path
34
+ def self.render_to_file(model, path, format: nil, **options)
35
+ format ||= detect_format(path)
36
+ output = render(model, format: format, **options)
37
+ File.write(path, output)
38
+ path
39
+ end
40
+
41
+ # Detect format from file extension
42
+ #
43
+ # @param path [String] File path
44
+ # @return [Symbol] Detected format
45
+ # @api private
46
+ def self.detect_format(path)
47
+ case File.extname(path).downcase
48
+ when ".html", ".htm" then :html
49
+ when ".dot", ".gv" then :graphviz
50
+ when ".puml", ".plantuml" then :plantuml
51
+ when ".mmd", ".mermaid" then :mermaid
52
+ else :html
53
+ end
54
+ end
55
+ private_class_method :detect_format
56
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: automograph
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Automograph is a generic automaton/state machine visualization library
13
+ that supports multiple output formats including Mermaid, GraphViz DOT, PlantUML,
14
+ and interactive HTML.
15
+ email:
16
+ - t.yudai92@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - lib/automograph.rb
23
+ - lib/automograph/formatter/base.rb
24
+ - lib/automograph/formatter/graphviz.rb
25
+ - lib/automograph/formatter/html.rb
26
+ - lib/automograph/formatter/mermaid.rb
27
+ - lib/automograph/formatter/plantuml.rb
28
+ - lib/automograph/model.rb
29
+ - lib/automograph/options.rb
30
+ - lib/automograph/version.rb
31
+ homepage: https://github.com/ydah/automograph
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ source_code_uri: https://github.com/ydah/automograph
36
+ changelog_uri: https://github.com/ydah/automograph/blob/master/CHANGELOG.md
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: 2.5.0
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.6.9
52
+ specification_version: 4
53
+ summary: Pure Ruby library for visualizing automata and state machines
54
+ test_files: []