state_machines-mermaid 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: 387fad10965ac7756257b30ac3a36b20770c2702631796c2727c3b0cb4eeea69
4
+ data.tar.gz: 26d45114695fab1ed434862f36520c4006f895d5db17903519995366e6045090
5
+ SHA512:
6
+ metadata.gz: c92e86302c88b96d81dded14771366d26225970a915e9077d0b71311fdd02cdc4e4b2a068311afbc1fca8e951e018d04fb4ed39f5e311c92035316e1acf97bb2
7
+ data.tar.gz: bb1ce73610a01f2892c2dc1b7737eff292b851e2a988e20a0bf13628a5c28e24a492cc9c6065714adb7278da03b7574354264f96c8a8619d714b143e5df032b3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # StateMachines::Mermaid
2
+
3
+ [![CI](https://github.com/state-machines/state_machines-mermaid/actions/workflows/ruby.yml/badge.svg)](https://github.com/state-machines/state_machines-mermaid/actions/workflows/ruby.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/state_machines-mermaid.svg)](https://rubygems.org/gems/state_machines-mermaid)
5
+ [![Supported Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.3.0-orange.svg)](https://www.ruby-lang.org)
6
+
7
+ Mermaid diagram renderer for [state_machines](https://github.com/state-machines/state_machines). Generate Mermaid state diagrams from your state machines.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'state_machines-mermaid'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle install
20
+
21
+ Or install it yourself as:
22
+
23
+ $ gem install state_machines-mermaid
24
+
25
+ ## Usage
26
+
27
+ ### Basic Usage
28
+
29
+ ```ruby
30
+ require 'state_machines-mermaid'
31
+
32
+ class Order
33
+ state_machine :status, initial: :pending do
34
+ event :process do
35
+ transition pending: :processing
36
+ end
37
+
38
+ event :ship do
39
+ transition processing: :shipped
40
+ end
41
+
42
+ event :deliver do
43
+ transition shipped: :delivered
44
+ end
45
+
46
+ event :cancel do
47
+ transition [:pending, :processing] => :cancelled
48
+ end
49
+
50
+ state :delivered, :cancelled do
51
+ def final?
52
+ true
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ # Generate Mermaid diagram
59
+ puts Order.state_machine(:status).draw
60
+ ```
61
+
62
+ This generates:
63
+
64
+ ```mermaid
65
+ stateDiagram-v2
66
+ pending : pending
67
+ processing : processing
68
+ shipped : shipped
69
+ delivered : delivered
70
+ cancelled : cancelled
71
+ pending --> processing : process
72
+ processing --> shipped : ship
73
+ shipped --> delivered : deliver
74
+ pending --> cancelled : cancel
75
+ processing --> cancelled : cancel
76
+ ```
77
+
78
+ ### With Conditions
79
+
80
+ ```ruby
81
+ class Character
82
+ state_machine :status, initial: :idle do
83
+ event :attack do
84
+ transition idle: :combat, if: :has_weapon?
85
+ end
86
+
87
+ event :rest do
88
+ transition combat: :idle, unless: :in_danger?
89
+ end
90
+ end
91
+ end
92
+
93
+ # Generates transitions with conditions
94
+ puts Character.state_machine(:status).draw
95
+ ```
96
+
97
+ Output includes conditions:
98
+
99
+ ```mermaid
100
+ stateDiagram-v2
101
+ idle : idle
102
+ combat : combat
103
+ idle --> combat : attack [if has_weapon?]
104
+ combat --> idle : rest [unless in_danger?]
105
+ ```
106
+
107
+ ### Show Callbacks
108
+
109
+ ```ruby
110
+ # Include callback information in the diagram
111
+ Character.state_machine(:status).draw(show_callbacks: true)
112
+ ```
113
+
114
+ ### Output to File
115
+
116
+ ```ruby
117
+ File.open('state_diagram.mmd', 'w') do |file|
118
+ Order.state_machine(:status).draw(io: file)
119
+ end
120
+ ```
121
+
122
+ ### Integration with Mermaid Tools
123
+
124
+ The generated output is compatible with:
125
+
126
+ - [Mermaid Live Editor](https://mermaid-js.github.io/mermaid-live-editor/)
127
+ - GitHub Markdown (renders Mermaid diagrams natively)
128
+ - Various documentation tools (GitLab, Notion, etc.)
129
+ - Mermaid CLI for generating PNG/SVG files
130
+
131
+ ### Example: Complex State Machine
132
+
133
+ ```ruby
134
+ class Dragon
135
+ state_machine :mood, initial: :sleeping do
136
+ state :sleeping, :hunting, :hoarding, :rampaging
137
+
138
+ event :wake_up do
139
+ transition sleeping: :hunting, if: :hungry?
140
+ transition sleeping: :hoarding, if: :treasure_nearby?
141
+ end
142
+
143
+ event :find_treasure do
144
+ transition hunting: :hoarding
145
+ transition hoarding: same # Keep hoarding
146
+ end
147
+
148
+ event :enrage do
149
+ transition any - :rampaging => :rampaging
150
+ end
151
+ end
152
+ end
153
+
154
+ puts Dragon.state_machine(:mood).draw
155
+ ```
156
+
157
+ ## Features
158
+
159
+ - Generates valid Mermaid state diagram syntax
160
+ - Supports initial and final states
161
+ - Shows transition conditions (if/unless)
162
+ - Handles self-transitions (loopbacks)
163
+ - Sanitizes state names for Mermaid compatibility
164
+ - Optional callback visualization
165
+ - Compatible with all state_machines features
166
+
167
+ ## Development
168
+
169
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
170
+
171
+ ## Contributing
172
+
173
+ Bug reports and pull requests are welcome on GitHub at https://github.com/state-machines/state_machines-mermaid.
174
+
175
+ ## License
176
+
177
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'state_machines-diagram'
4
+ require 'mermaid'
5
+
6
+ module StateMachines
7
+ module Mermaid
8
+ module Renderer
9
+ extend self
10
+
11
+ # Cache recent metadata so we can enhance the Mermaid output with
12
+ # condition/callback details when the caller requests it.
13
+ def reset_metadata!
14
+ @last_state_metadata = nil
15
+ @last_transition_metadata = nil
16
+ @last_transition_metadata_map = nil
17
+ end
18
+
19
+ # The new simplified approach - leverages the diagram gem's semantic structure
20
+ def draw_machine(machine, io: $stdout, **options)
21
+ reset_metadata!
22
+ diagram = build_state_diagram(machine, options)
23
+ output_diagram(diagram, io, options)
24
+ diagram
25
+ end
26
+
27
+ def build_state_diagram(machine, options)
28
+ builder = StateMachines::Diagram::Builder.new(machine, options)
29
+ diagram = builder.build
30
+
31
+ @last_state_metadata = builder.state_metadata
32
+ @last_transition_metadata = builder.transition_metadata
33
+ @last_transition_metadata_map = nil
34
+
35
+ diagram
36
+ end
37
+
38
+ def draw_state(state, graph, options = {}, io = $stdout)
39
+ reset_metadata!
40
+ diagram = build_state_diagram(state.machine, options)
41
+ mermaid_syntax = render_mermaid(diagram, options)
42
+ # Filter to show only relevant transitions for this state
43
+ filtered_lines = filter_mermaid_for_state(mermaid_syntax, state.name.to_s)
44
+ io.puts filtered_lines
45
+ diagram
46
+ end
47
+
48
+ def draw_event(event, graph, options = {}, io = $stdout)
49
+ reset_metadata!
50
+ diagram = build_state_diagram(event.machine, options)
51
+ mermaid_syntax = render_mermaid(diagram, options)
52
+ # Filter to show only transitions triggered by this event
53
+ filtered_lines = filter_mermaid_for_event(mermaid_syntax, event.name.to_s)
54
+ io.puts filtered_lines
55
+ diagram
56
+ end
57
+
58
+ # The core method - just delegates to the mermaid gem's to_mermaid method
59
+ def output_diagram(diagram, io, options)
60
+ mermaid_syntax = render_mermaid(diagram, options)
61
+ io.puts mermaid_syntax
62
+ mermaid_syntax
63
+ end
64
+
65
+ private
66
+
67
+ def transition_metadata_map
68
+ @last_transition_metadata_map ||= Array(@last_transition_metadata).each_with_object({}) do |data, memo|
69
+ transition = data[:transition]
70
+ memo[transition] = data if transition
71
+ end
72
+ end
73
+
74
+ def render_mermaid(diagram, options)
75
+ return diagram.to_mermaid unless @last_transition_metadata
76
+
77
+ lines = ["stateDiagram-v2"]
78
+
79
+ diagram.states.each do |state|
80
+ fragment = state.respond_to?(:to_mermaid_fragment) ? state.to_mermaid_fragment : state.id
81
+ lines << " #{fragment}" if fragment && !fragment.empty?
82
+ end
83
+
84
+ diagram.transitions.each do |transition|
85
+ metadata = transition_metadata_map[transition]
86
+ lines << " #{render_transition_line(transition, metadata, options)}"
87
+ end
88
+
89
+ lines.join("\n")
90
+ end
91
+
92
+ def render_transition_line(transition, metadata, options)
93
+ from_node = format_node_id(transition.source_state_id)
94
+ to_node = format_node_id(transition.target_state_id)
95
+
96
+ label_text = ''
97
+
98
+ base_label = transition.label
99
+ label_text = base_label if base_label && !base_label.empty?
100
+
101
+ guard_fragment = ''
102
+ if options[:show_conditions] && metadata
103
+ condition_tokens = build_condition_tokens(metadata[:conditions])
104
+ guard_fragment = "[#{condition_tokens.join(' && ')}]" if condition_tokens.any?
105
+ end
106
+
107
+ action_fragment = ''
108
+ if options[:show_callbacks] && metadata
109
+ callback_tokens = build_callback_tokens(metadata[:callbacks])
110
+ action_fragment = "/ #{callback_tokens.join(', ')}" if callback_tokens.any?
111
+ end
112
+
113
+ parts = []
114
+ parts << label_text unless label_text.empty?
115
+ parts << guard_fragment unless guard_fragment.empty?
116
+ parts << action_fragment unless action_fragment.empty?
117
+ label = parts.join(' ').strip
118
+
119
+ if label.empty?
120
+ "#{from_node} --> #{to_node}"
121
+ else
122
+ "#{from_node} --> #{to_node} : #{label}"
123
+ end
124
+ end
125
+
126
+ def build_condition_tokens(conditions)
127
+ return [] unless conditions.is_a?(Hash)
128
+
129
+ tokens = []
130
+ Array(conditions[:if]).each do |token|
131
+ next if token.nil? || token.to_s.empty?
132
+ tokens << "if #{token}"
133
+ end
134
+ Array(conditions[:unless]).each do |token|
135
+ next if token.nil? || token.to_s.empty?
136
+ tokens << "unless #{token}"
137
+ end
138
+ tokens
139
+ end
140
+
141
+ def build_callback_tokens(callbacks)
142
+ return [] unless callbacks.is_a?(Hash)
143
+
144
+ tokens = []
145
+ callbacks.each do |type, names|
146
+ Array(names).each do |name|
147
+ next if name.nil? || name.to_s.empty?
148
+ tokens << "#{type} #{format_callback_reference(name)}"
149
+ end
150
+ end
151
+ tokens
152
+ end
153
+
154
+ def format_callback_reference(callback)
155
+ case callback
156
+ when Symbol, String
157
+ callback.to_s
158
+ when Proc, Method
159
+ if callback.respond_to?(:source_location) && callback.source_location
160
+ file, line = callback.source_location
161
+ filename = File.basename(file) if file
162
+ "lambda@#{filename}:#{line}"
163
+ else
164
+ 'lambda'
165
+ end
166
+ else
167
+ callback.to_s
168
+ end
169
+ end
170
+
171
+ def format_node_id(node_id)
172
+ node_id == '*' ? '[*]' : node_id
173
+ end
174
+
175
+ def filter_mermaid_for_state(mermaid_syntax, state_name)
176
+ lines = mermaid_syntax.split("\n")
177
+ relevant_lines = lines.select do |line|
178
+ line.include?(state_name) || line.start_with?("stateDiagram")
179
+ end
180
+ relevant_lines.join("\n")
181
+ end
182
+
183
+ def filter_mermaid_for_event(mermaid_syntax, event_name)
184
+ lines = mermaid_syntax.split("\n")
185
+ relevant_lines = lines.select do |line|
186
+ line.include?(event_name) || line.start_with?("stateDiagram")
187
+ end
188
+ relevant_lines.join("\n")
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module Mermaid
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'state_machines'
4
+ require 'state_machines-diagram'
5
+ require 'state_machines/mermaid/version'
6
+ require 'state_machines/mermaid/renderer'
7
+
8
+ # Set the renderer to use mermaid
9
+ StateMachines::Machine.renderer = StateMachines::Mermaid::Renderer
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+ require 'stringio'
5
+
6
+ class MermaidRendererTest < Minitest::Test
7
+ def setup
8
+ @dragon = Dragon.new
9
+ @mage = Mage.new
10
+ @troll = Troll.new
11
+ @regiment = Regiment.new
12
+ @battle = Battle.new
13
+ end
14
+
15
+ def test_basic_mermaid_output
16
+ io = StringIO.new
17
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:mood), io: io)
18
+
19
+ output = io.string
20
+ assert_includes output, "stateDiagram-v2"
21
+ assert_includes output, "sleeping : sleeping"
22
+ assert_includes output, "sleeping --> hunting : wake_up"
23
+ end
24
+
25
+ def test_state_labels
26
+ io = StringIO.new
27
+ StateMachines::Mermaid::Renderer.draw_machine(@mage.class.state_machine(:concentration), io: io)
28
+
29
+ output = io.string
30
+ assert_includes output, "focused : focused"
31
+ assert_includes output, "deep_meditation : deep_meditation"
32
+ end
33
+
34
+ def test_complex_transitions_with_conditions
35
+ io = StringIO.new
36
+ StateMachines::Mermaid::Renderer.draw_machine(
37
+ Character.state_machine(:status),
38
+ io: io,
39
+ show_conditions: true
40
+ )
41
+
42
+ output = io.string
43
+ assert_includes output, "idle --> combat : engage"
44
+ assert_includes output, "[if can_fight?]"
45
+ assert_includes output, "[unless spell_locked?]"
46
+ end
47
+
48
+ def test_final_states
49
+ io = StringIO.new
50
+ StateMachines::Mermaid::Renderer.draw_machine(Character.state_machine(:status), io: io)
51
+
52
+ output = io.string
53
+ # The mermaid gem doesn't add [*] for final states by default
54
+ assert_includes output, "dead : dead"
55
+ end
56
+
57
+ def test_multiple_from_states
58
+ io = StringIO.new
59
+ StateMachines::Mermaid::Renderer.draw_machine(@troll.class.state_machine(:regeneration), io: io)
60
+
61
+ output = io.string
62
+ assert_includes output, "normal --> suppressed : take_fire_damage"
63
+ assert_includes output, "accelerated --> suppressed : take_fire_damage"
64
+ assert_includes output, "berserk --> suppressed : take_fire_damage"
65
+ end
66
+
67
+ def test_loopback_transitions
68
+ io = StringIO.new
69
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:mood), io: io)
70
+
71
+ output = io.string
72
+ assert_includes output, "hoarding --> hoarding : find_treasure"
73
+ end
74
+
75
+ def test_callbacks_option
76
+ io = StringIO.new
77
+ StateMachines::Mermaid::Renderer.draw_machine(
78
+ Character.state_machine(:status),
79
+ io: io,
80
+ show_callbacks: true
81
+ )
82
+
83
+ output = io.string
84
+ assert_includes output, " / before"
85
+ assert_includes output, "cast_spell"
86
+ end
87
+
88
+ def test_nested_states_in_spell_school
89
+ io = StringIO.new
90
+ StateMachines::Mermaid::Renderer.draw_machine(@mage.class.state_machine(:spell_school), io: io)
91
+
92
+ output = io.string
93
+ assert_includes output, "apprentice"
94
+ assert_includes output, "ember"
95
+ assert_includes output, "inferno"
96
+ assert_includes output, "archmage"
97
+ end
98
+
99
+ def test_battle_phase_machine
100
+ io = StringIO.new
101
+ StateMachines::Mermaid::Renderer.draw_machine(@battle.class.state_machine(:phase), io: io)
102
+
103
+ output = io.string
104
+ assert_includes output, "preparation"
105
+ assert_includes output, "deployment"
106
+ assert_includes output, "skirmish"
107
+ assert_includes output, "main_battle"
108
+ assert_includes output, "climax"
109
+ assert_includes output, "aftermath"
110
+ end
111
+
112
+ def test_regiment_formation_states
113
+ io = StringIO.new
114
+ StateMachines::Mermaid::Renderer.draw_machine(@regiment.class.state_machine(:formation), io: io)
115
+
116
+ output = io.string
117
+ assert_includes output, "column"
118
+ assert_includes output, "square"
119
+ assert_includes output, "wedge"
120
+ assert_includes output, "scattered"
121
+ assert_includes output, "column --> line : deploy"
122
+ end
123
+
124
+ def test_sanitizes_special_characters_in_ids
125
+ # Create a class with special characters in state names
126
+ klass = Class.new do
127
+ state_machine :status do
128
+ state :"waiting-for-input"
129
+ state :"processing/data"
130
+ state :"error!"
131
+
132
+ event :process do
133
+ transition :"waiting-for-input" => :"processing/data"
134
+ end
135
+
136
+ event :fail do
137
+ transition :"processing/data" => :"error!"
138
+ end
139
+ end
140
+ end
141
+
142
+ io = StringIO.new
143
+ StateMachines::Mermaid::Renderer.draw_machine(klass.state_machine(:status), io: io)
144
+
145
+ output = io.string
146
+ # The mermaid gem doesn't sanitize IDs by default
147
+ assert_includes output, "waiting-for-input"
148
+ assert_includes output, "processing/data"
149
+ assert_includes output, "error!"
150
+ assert_includes output, "waiting-for-input --> processing/data"
151
+ end
152
+
153
+ def test_dragon_age_progression
154
+ io = StringIO.new
155
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:age_category), io: io)
156
+
157
+ output = io.string
158
+ assert_includes output, "wyrmling --> young : age"
159
+ assert_includes output, "young --> adult : age"
160
+ assert_includes output, "adult --> ancient : age"
161
+ assert_includes output, "ancient --> great_wyrm : age"
162
+ end
163
+
164
+ def test_parallel_state_machines_dragon
165
+ mood_io = StringIO.new
166
+ flight_io = StringIO.new
167
+ age_io = StringIO.new
168
+
169
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:mood), io: mood_io)
170
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:flight), io: flight_io)
171
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:age_category), io: age_io)
172
+
173
+ # Each should generate valid mermaid syntax
174
+ [mood_io, flight_io, age_io].each do |io|
175
+ assert io.string.start_with?("stateDiagram-v2")
176
+ assert io.string.include?("-->")
177
+ end
178
+ end
179
+
180
+ def test_complex_conditions_formatting
181
+ io = StringIO.new
182
+ StateMachines::Mermaid::Renderer.draw_machine(@dragon.class.state_machine(:mood), io: io)
183
+
184
+ output = io.string
185
+ # Check that complex conditions are properly formatted
186
+ lines = output.split("\n")
187
+ transition_lines = lines.select { |l| l.include?("-->") && l.include?("[") }
188
+
189
+ # Check that at least some transitions exist
190
+ assert output.include?("-->"), "Should have transitions"
191
+ end
192
+
193
+ def test_all_character_types_render_properly
194
+ [@dragon, @mage, @troll, @regiment].each do |character|
195
+ character.class.state_machines.each do |name, machine|
196
+ io = StringIO.new
197
+ # Just call the method, any exception will fail the test
198
+ StateMachines::Mermaid::Renderer.draw_machine(machine, io: io)
199
+
200
+ output = io.string
201
+ assert output.start_with?("stateDiagram-v2"),
202
+ "#{character.class.name}##{name} should generate valid mermaid"
203
+ assert output.include?("-->"),
204
+ "#{character.class.name}##{name} should have transitions"
205
+ end
206
+ end
207
+ end
208
+ end