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 +7 -0
- data/README.md +101 -0
- data/lib/automograph/formatter/base.rb +71 -0
- data/lib/automograph/formatter/graphviz.rb +86 -0
- data/lib/automograph/formatter/html.rb +149 -0
- data/lib/automograph/formatter/mermaid.rb +58 -0
- data/lib/automograph/formatter/plantuml.rb +68 -0
- data/lib/automograph/model.rb +202 -0
- data/lib/automograph/options.rb +65 -0
- data/lib/automograph/version.rb +5 -0
- data/lib/automograph.rb +56 -0
- metadata +54 -0
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()">×</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
|
data/lib/automograph.rb
ADDED
|
@@ -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: []
|