state_machines-diagram 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: dea8376942dd89e9671884fffabcf95cefb34e39e4c6366a6413deca1f965d0f
4
+ data.tar.gz: 9dbc0c5784bcf3b68b07f57fcde2681b7623d05ace83582dfe0c7a8c8e3a8c66
5
+ SHA512:
6
+ metadata.gz: ff56f38bbb64f4d539f5b6418e6ffa8f90627f47c1401b30f63f845815ce666bdc5b0661ffc019383346e0fd99fa896c75e8425d31f894c84c24ccfee9d82cd0
7
+ data.tar.gz: 1345f579d826b86595a08b7a1a7c82d1521ccd83bb53af1165bc870b91b811af96ab9bc7e267d0ad610d11c2f08b0cea834948c1de3bc624258922de42e08d60
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 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,349 @@
1
+ # StateMachines::Diagram - Extensible Core for State Machine Visualization
2
+
3
+ [![CI](https://github.com/state-machines/state_machines-diagram/actions/workflows/ruby.yml/badge.svg?branch=master)](https://github.com/state-machines/state_machines-diagram/actions/workflows/ruby.yml)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
5
+ [![Ruby](https://img.shields.io/badge/ruby-3.3%2B-red.svg)](state_machines-diagram.gemspec)
6
+
7
+ An extensible diagram building foundation for the state_machines ecosystem. This gem provides a structured intermediate representation (IR) and adapter framework that enables consistent visualization across multiple output formats.
8
+
9
+ ## Quick Start
10
+
11
+ ```ruby
12
+ # 1. Define a state machine using any supported library
13
+ require 'state_machines'
14
+
15
+ class Order
16
+ state_machine :status, initial: :pending do
17
+ state :pending, :processing, :shipped, :cancelled
18
+
19
+ event :process do
20
+ transition pending: :processing, if: :payment_cleared?
21
+ end
22
+
23
+ event :ship do
24
+ transition processing: :shipped, action: :send_notification
25
+ end
26
+
27
+ event :cancel do
28
+ transition [:pending, :processing] => :cancelled
29
+ end
30
+ end
31
+
32
+ def payment_cleared?
33
+ @payment_cleared ||= false
34
+ end
35
+
36
+ def send_notification
37
+ puts "Order shipped!"
38
+ end
39
+ end
40
+
41
+ # 2. Generate diagram representation
42
+ require 'state_machines-diagram'
43
+
44
+ # Text format (default)
45
+ Order.state_machine(:status).draw
46
+ # Output:
47
+ # Status: pending → processing [process] (if: payment_cleared?)
48
+ # Status: processing → shipped [ship] (action: send_notification)
49
+ # Status: pending → cancelled [cancel]
50
+ # Status: processing → cancelled [cancel]
51
+
52
+ # JSON structure
53
+ Order.state_machine(:status).draw(format: :json)
54
+ # Output: {"states": [...], "transitions": [...]}
55
+
56
+ # With specific rendering gem
57
+ require 'state_machines-mermaid'
58
+ Order.state_machine(:status).draw(format: :mermaid)
59
+ # Output:
60
+ # stateDiagram-v2
61
+ # pending --> processing : process (if: payment_cleared?)
62
+ # processing --> shipped : ship (action: send_notification)
63
+ # pending --> cancelled : cancel
64
+ # processing --> cancelled : cancel
65
+ ```
66
+
67
+ ## Architecture
68
+
69
+ This gem implements a structured data transformation pipeline:
70
+
71
+ ```
72
+ StateMachine → Builder → Diagrams::StateDiagram → Renderer → Output
73
+ ```
74
+
75
+ ### Core Components
76
+
77
+ #### 1. Intermediate Representation (IR)
78
+
79
+ The `Diagrams::StateDiagram` IR uses immutable `Dry::Struct` objects to represent state machine structure:
80
+
81
+ ```ruby
82
+ # State representation
83
+ Diagrams::Elements::State = Dry::Struct do
84
+ attribute :id, Types::Strict::String
85
+ attribute :state_type, Types::StateType # :initial, :final, :normal
86
+ attribute :name, Types::Strict::String.optional
87
+ end
88
+
89
+ # Transition representation with semantic information
90
+ Diagrams::Elements::Transition = Dry::Struct do
91
+ attribute :source_state_id, Types::Strict::String
92
+ attribute :target_state_id, Types::Strict::String
93
+ attribute :label, Types::Strict::String.optional
94
+ attribute :guard, Types::Strict::String.optional # if: condition
95
+ attribute :action, Types::Strict::String.optional # callback method
96
+ end
97
+
98
+ # Complete diagram structure
99
+ Diagrams::StateDiagram = Dry::Struct do
100
+ attribute :states, Types::Array.of(Diagrams::Elements::State)
101
+ attribute :transitions, Types::Array.of(Diagrams::Elements::Transition)
102
+ attribute :title, Types::Strict::String.optional
103
+ end
104
+ ```
105
+
106
+ #### 2. Builder Contract
107
+
108
+ The `StateMachines::Diagram::Builder` extracts semantic information from state machines:
109
+
110
+ ```ruby
111
+ # Core building method
112
+ diagram = StateMachines::Diagram::Builder.build_state_diagram(machine, options)
113
+
114
+ # Builder handles:
115
+ # - State extraction with type detection
116
+ # - Transition mapping with guard conditions
117
+ # - Callback extraction (before/after/around)
118
+ # - Event-to-transition resolution
119
+ # - Filtering (state_filter, event_filter)
120
+ ```
121
+
122
+ #### 3. Renderer Interface
123
+
124
+ Renderers implement a standardized contract:
125
+
126
+ ```ruby
127
+ module StateMachines::Diagram::Renderer
128
+ # Required method for all renderers
129
+ def self.draw_machine(machine, io: $stdout, **options)
130
+ diagram = build_state_diagram(machine, options)
131
+ output_diagram(diagram, io, options)
132
+ end
133
+
134
+ private
135
+
136
+ # Override this method for custom output
137
+ def self.output_diagram(diagram, io, options)
138
+ # Your rendering logic here
139
+ end
140
+ end
141
+ ```
142
+
143
+ ## Error Handling
144
+
145
+ The gem provides robust error handling for common edge cases:
146
+
147
+ ```ruby
148
+ begin
149
+ diagram = StateMachines::Diagram::Builder.build_state_diagram(machine)
150
+ rescue StateMachines::Diagram::InvalidStateError => e
151
+ puts "Invalid state configuration: #{e.message}"
152
+ rescue StateMachines::Diagram::TransitionError => e
153
+ puts "Transition error: #{e.message}"
154
+ end
155
+
156
+ # Validate diagram structure
157
+ if diagram.states.empty?
158
+ raise StateMachines::Diagram::EmptyDiagramError, "No states found"
159
+ end
160
+ ```
161
+
162
+ ## Advanced Usage
163
+
164
+ ### Custom Renderer Example
165
+
166
+ ```ruby
167
+ module MyPlantUMLRenderer
168
+ extend StateMachines::Diagram::Renderer
169
+
170
+ private
171
+
172
+ def self.output_diagram(diagram, io, options)
173
+ io.puts "@startuml"
174
+ io.puts "title #{diagram.title}" if diagram.title
175
+
176
+ diagram.states.each do |state|
177
+ io.puts "state #{state.id}"
178
+ end
179
+
180
+ diagram.transitions.each do |transition|
181
+ line = "#{transition.source_state_id} --> #{transition.target_state_id}"
182
+ line += " : #{transition.label}" if transition.label
183
+
184
+ annotations = []
185
+ annotations << "#{transition.guard}" if transition.guard
186
+ annotations << "#{transition.action}" if transition.action
187
+ line += " [#{annotations.join(', ')}]" unless annotations.empty?
188
+
189
+ io.puts line
190
+ end
191
+
192
+ io.puts "@enduml"
193
+ end
194
+ end
195
+
196
+ # Use the custom renderer
197
+ StateMachines::Machine.renderer = MyPlantUMLRenderer
198
+ Order.state_machine(:status).draw
199
+ ```
200
+
201
+ ### Complex State Machine Support
202
+
203
+ ```ruby
204
+ class Dragon
205
+ # Multiple parallel state machines
206
+ state_machine :mood, initial: :sleeping do
207
+ state :sleeping, :hunting, :hoarding
208
+
209
+ event :wake_up do
210
+ transition sleeping: :hunting, if: :hungry?
211
+ transition sleeping: :hoarding, unless: :hungry?
212
+ end
213
+
214
+ event :find_treasure do
215
+ transition hoarding: :hoarding # Self-transition
216
+ end
217
+ end
218
+
219
+ state_machine :flight, initial: :grounded do
220
+ state :grounded, :airborne
221
+
222
+ event :take_off do
223
+ transition grounded: :airborne, action: :spread_wings
224
+ end
225
+ end
226
+
227
+ def hungry?
228
+ @hunger_level > 5
229
+ end
230
+
231
+ def spread_wings
232
+ puts "Dragon spreads mighty wings!"
233
+ end
234
+ end
235
+
236
+ # Generate diagrams for each state machine
237
+ Dragon.state_machine(:mood).draw(show_conditions: true)
238
+ Dragon.state_machine(:flight).draw(show_callbacks: true)
239
+ ```
240
+
241
+ ### Filtering and Options
242
+
243
+ ```ruby
244
+ # Focus on specific state
245
+ Order.state_machine(:status).draw(state_filter: :processing)
246
+
247
+ # Focus on specific event
248
+ Order.state_machine(:status).draw(event_filter: :ship)
249
+
250
+ # Show semantic information
251
+ Order.state_machine(:status).draw(show_conditions: true, show_callbacks: true)
252
+
253
+ # Human-readable names
254
+ Order.state_machine(:status).draw(human_names: true)
255
+
256
+ # Output to file
257
+ File.open('order_diagram.json', 'w') do |file|
258
+ Order.state_machine(:status).draw(io: file, format: :json)
259
+ end
260
+ ```
261
+
262
+ ## Testing Strategy
263
+
264
+ The gem includes comprehensive test coverage with robust fixtures:
265
+
266
+ ```bash
267
+ # Run all tests
268
+ rake test
269
+
270
+ # Test specific components
271
+ ruby -Itest test/unit/builder_test.rb
272
+ ruby -Itest test/unit/renderer_test.rb
273
+
274
+ # Lint code
275
+ bundle exec rubocop
276
+ ```
277
+
278
+ ### Test Coverage
279
+
280
+ - **Builder Tests**: State extraction, transition mapping, guard condition handling
281
+ - **Renderer Tests**: Output format validation, option handling, error cases
282
+ - **Integration Tests**: End-to-end workflows with complex state machines
283
+ - **Edge Case Tests**: Invalid states, circular transitions, missing callbacks
284
+
285
+ ## Ecosystem Integration
286
+
287
+ This gem serves as the foundation for rendering-specific gems:
288
+
289
+ - **`state_machines-diagram`** (this gem): Core diagram building and IR
290
+ - **`state_machines-mermaid`**: Mermaid syntax renderer
291
+ - **`state_machines-graphviz`**: GraphViz DOT format renderer (planned)
292
+ - **Custom renderers**: PlantUML, SVG, ASCII art, etc.
293
+
294
+ ### Supported State Machine Libraries
295
+
296
+ - `state_machines` (primary)
297
+ - `state_machines-activerecord`
298
+ - `state_machines-activemodel`
299
+
300
+ ### Ruby Version Support
301
+
302
+ - **Ruby 3.3+**: Required for pattern matching and modern syntax
303
+ - **Rails 7.2+**: Optional, for ActiveRecord/ActiveModel integration
304
+
305
+ ## Performance Considerations
306
+
307
+ - **Immutable IR**: Uses `Dry::Struct` for thread-safe, immutable diagram objects
308
+ - **Lazy Evaluation**: Diagrams are built only when `draw` is called
309
+ - **Memory Efficient**: No global state or caching by default
310
+ - **Streaming Support**: Large diagrams can be rendered incrementally
311
+
312
+ ## Contributing
313
+
314
+ 1. Fork the repository
315
+ 2. Create a feature branch (`git checkout -b my-new-feature`)
316
+ 3. Add tests for your changes
317
+ 4. Ensure all tests pass (`rake test`)
318
+ 5. Ensure code style compliance (`bundle exec rubocop`)
319
+ 6. Submit a pull request
320
+
321
+ ### Adding a New State Machine Library
322
+
323
+ To support a new state machine library, implement the builder pattern:
324
+
325
+ ```ruby
326
+ module StateMachines::Diagram::Adapters
327
+ class MyLibraryAdapter
328
+ def initialize(machine)
329
+ @machine = machine
330
+ end
331
+
332
+ def build_states
333
+ # Extract states from @machine
334
+ end
335
+
336
+ def build_transitions
337
+ # Extract transitions from @machine
338
+ end
339
+ end
340
+ end
341
+ ```
342
+
343
+ ## License
344
+
345
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
346
+
347
+ ## Changelog
348
+
349
+ See [CHANGELOG.md](CHANGELOG.md) for version history and breaking changes.
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'diagram'
4
+
5
+ module StateMachines
6
+ module Diagram
7
+ class Builder
8
+ attr_reader :machine, :options, :diagram, :state_metadata, :transition_metadata
9
+
10
+ def initialize(machine, options = {})
11
+ @machine = machine
12
+ @options = options
13
+ @diagram = create_diagram
14
+ @state_metadata = {}
15
+ @transition_metadata = []
16
+ end
17
+
18
+ def build
19
+ add_states
20
+ add_transitions
21
+ diagram
22
+ end
23
+
24
+ private
25
+
26
+ def create_diagram
27
+ ::Diagrams::StateDiagram.new(
28
+ title: diagram_title
29
+ )
30
+ end
31
+
32
+ def diagram_id
33
+ "#{machine.owner_class.name}_#{machine.name}"
34
+ end
35
+
36
+ def diagram_title
37
+ "#{machine.owner_class.name} #{machine.name} State Machine"
38
+ end
39
+
40
+ def diagram_description
41
+ "State machine for #{machine.owner_class.name}##{machine.name}"
42
+ end
43
+
44
+ def add_states
45
+ machine.states.by_priority.each do |state|
46
+ add_state_node(state)
47
+ end
48
+ end
49
+
50
+ def add_state_node(state)
51
+ state_node = ::Diagrams::Elements::State.new(
52
+ id: state_id(state),
53
+ label: state_label(state)
54
+ )
55
+
56
+ diagram.add_state(state_node)
57
+
58
+ # Store metadata separately for now
59
+ @state_metadata ||= {}
60
+ @state_metadata[state_id(state)] = {
61
+ type: state_type(state),
62
+ metadata: build_state_metadata(state)
63
+ }
64
+ end
65
+
66
+ def state_id(state)
67
+ state.name ? state.name.to_s : 'nil_state'
68
+ end
69
+
70
+ def state_label(state)
71
+ if options[:human_names]
72
+ state.human_name(machine.owner_class)
73
+ else
74
+ state_id(state)
75
+ end
76
+ end
77
+
78
+ def state_type(state)
79
+ if state.initial?
80
+ 'initial'
81
+ elsif state.final?
82
+ 'final'
83
+ else
84
+ 'normal'
85
+ end
86
+ end
87
+
88
+ def build_state_metadata(state)
89
+ {
90
+ initial: state.initial?,
91
+ final: state.final?,
92
+ value: state.value,
93
+ methods: state.methods.grep(/^#{state.name}_/).map(&:to_s)
94
+ }
95
+ end
96
+
97
+ def add_transitions
98
+ machine.events.each do |event|
99
+ add_event_transitions(event)
100
+ end
101
+ end
102
+
103
+ def add_event_transitions(event)
104
+ event.branches.each do |branch|
105
+ add_branch_transitions(branch, event)
106
+ end
107
+ end
108
+
109
+ def add_branch_transitions(branch, event)
110
+ valid_states = machine.states.by_priority.map(&:name)
111
+
112
+ branch.state_requirements.each do |requirement|
113
+ from_states = requirement[:from].filter(valid_states)
114
+ to_states = determine_to_states(requirement, from_states)
115
+
116
+ create_transitions(from_states, to_states, event, branch)
117
+ end
118
+ end
119
+
120
+ def determine_to_states(requirement, from_states)
121
+ if requirement[:to].values.empty?
122
+ # Loopback transitions
123
+ from_states
124
+ else
125
+ [requirement[:to].values.first]
126
+ end
127
+ end
128
+
129
+ def create_transitions(from_states, to_states, event, branch)
130
+ from_states.each do |from|
131
+ to_states.each do |to|
132
+ add_transition(from, to, event, branch)
133
+ end
134
+ end
135
+ end
136
+
137
+ def add_transition(from, to, event, branch)
138
+ from_id = from ? from.to_s : 'nil_state'
139
+ to_id = to ? to.to_s : 'nil_state'
140
+
141
+ guard_info = extract_guard_conditions(branch)
142
+
143
+ transition = ::Diagrams::Elements::Transition.new(
144
+ source_state_id: from_id.empty? ? 'nil_state' : from_id,
145
+ target_state_id: to_id.empty? ? 'nil_state' : to_id,
146
+ label: transition_label(event),
147
+ guard: guard_info[:display],
148
+ action: extract_action_info(branch, event)
149
+ )
150
+
151
+ diagram.add_transition(transition)
152
+
153
+ # Store additional metadata separately for advanced analysis
154
+ @transition_metadata ||= []
155
+ transition_data = {
156
+ transition: transition,
157
+ from: from.to_s,
158
+ to: to.to_s,
159
+ event: event.name.to_s,
160
+ conditions: guard_info[:conditions],
161
+ callbacks: build_callbacks(branch, event),
162
+ metadata: build_transition_metadata(event, branch)
163
+ }
164
+ @transition_metadata << transition_data
165
+ end
166
+
167
+ def transition_label(event)
168
+ if options[:human_names]
169
+ event.human_name(machine.owner_class)
170
+ else
171
+ event.name.to_s
172
+ end
173
+ end
174
+
175
+ def build_callbacks(branch, event)
176
+ {
177
+ before: callback_method_names(branch, event, :before),
178
+ after: callback_method_names(branch, event, :after),
179
+ around: callback_method_names(branch, event, :around)
180
+ }
181
+ end
182
+
183
+ def callback_method_names(branch, event, type)
184
+ machine.callbacks[type == :around ? :before : type].select do |callback|
185
+ callback.branch.matches?(branch,
186
+ from: branch.state_requirements.map { |req| req[:from] },
187
+ to: branch.state_requirements.map { |req| req[:to] },
188
+ on: event.name)
189
+ end.flat_map do |callback|
190
+ callback.instance_variable_get('@methods')
191
+ end.compact
192
+ end
193
+
194
+ def build_transition_metadata(_event, branch)
195
+ {
196
+ requirements: branch.state_requirements.size
197
+ }
198
+ end
199
+
200
+ def extract_guard_conditions(branch)
201
+ guard_conditions = {
202
+ if: [],
203
+ unless: []
204
+ }
205
+ condition_tokens = []
206
+
207
+ if branch.if_condition
208
+ token = guard_condition_token(branch.if_condition)
209
+ guard_conditions[:if] << token if token
210
+ condition_tokens << token if token
211
+ end
212
+
213
+ if branch.unless_condition
214
+ token = guard_condition_token(branch.unless_condition)
215
+ guard_conditions[:unless] << token if token
216
+ condition_tokens << "!#{token}" if token
217
+ end
218
+
219
+ {
220
+ display: condition_tokens.empty? ? nil : condition_tokens.join(' && '),
221
+ conditions: guard_conditions
222
+ }
223
+ end
224
+
225
+ def guard_condition_token(condition)
226
+ return if condition.nil?
227
+
228
+ case condition
229
+ when Symbol
230
+ method_name = condition.to_s
231
+ method_name += '?' unless method_name.end_with?('?')
232
+ method_name
233
+ when Proc, Method
234
+ if condition.respond_to?(:source_location) && condition.source_location
235
+ file, line = condition.source_location
236
+ filename = File.basename(file) if file
237
+ "lambda@#{filename}:#{line}"
238
+ else
239
+ 'lambda'
240
+ end
241
+ else
242
+ condition.to_s
243
+ end
244
+ end
245
+
246
+ def extract_action_info(branch, event)
247
+ actions = []
248
+
249
+ # Add explicit action if available (store the object, format during rendering)
250
+ actions << event.action if event.respond_to?(:action) && event.action
251
+
252
+ # Add callback methods as actions
253
+ before_callbacks = callback_method_names(branch, event, :before)
254
+ after_callbacks = callback_method_names(branch, event, :after)
255
+
256
+ actions.concat(before_callbacks) if before_callbacks.any?
257
+ actions.concat(after_callbacks) if after_callbacks.any?
258
+
259
+ return nil if actions.empty?
260
+
261
+ # Format all actions appropriately
262
+ formatted_actions = actions.map { |action| format_action(action) }
263
+ formatted_actions.join(', ')
264
+ end
265
+
266
+ def format_action(action)
267
+ case action
268
+ when Proc, Method
269
+ # Try to extract source location for better readability
270
+ if action.respond_to?(:source_location) && action.source_location
271
+ file, line = action.source_location
272
+ filename = File.basename(file) if file
273
+ "lambda@#{filename}:#{line}"
274
+ else
275
+ 'lambda'
276
+ end
277
+ when Symbol
278
+ action.to_s
279
+ else
280
+ action.to_s
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'builder'
4
+ require 'set'
5
+
6
+ module StateMachines
7
+ module Diagram
8
+ module Renderer
9
+ module_function
10
+
11
+ def draw_machine(machine, io: $stdout, **options)
12
+ diagram, builder = build_state_diagram(machine, options)
13
+ output_diagram(diagram, io, options, builder)
14
+ diagram
15
+ end
16
+
17
+ def build_state_diagram(machine, options)
18
+ builder = Builder.new(machine, options)
19
+ diagram = builder.build
20
+ [diagram, builder]
21
+ end
22
+
23
+ def output_diagram(diagram, io, options, builder = nil)
24
+ case options[:format]
25
+ when :json
26
+ io.puts diagram_hash_with_metadata(diagram, builder).to_json
27
+ when :yaml
28
+ require 'yaml'
29
+ io.puts diagram_hash_with_metadata(diagram, builder).to_yaml
30
+ else
31
+ # Default text representation
32
+ io.puts diagram_to_text(diagram, options, builder)
33
+ end
34
+ end
35
+
36
+ def draw_state(state, _graph, options = {}, io = $stdout)
37
+ # Build a diagram containing just this state and its transitions
38
+ machine = state.machine
39
+
40
+ # Find all states involved with this state
41
+ states_involved = Set.new([state.name])
42
+
43
+ machine.events.each do |event|
44
+ event.branches.each do |branch|
45
+ branch.state_requirements.each do |requirement|
46
+ valid_states = machine.states.by_priority.map(&:name)
47
+ from_states = requirement[:from].filter(valid_states)
48
+ to_states = requirement[:to].values
49
+
50
+ if from_states.include?(state.name)
51
+ states_involved.merge(to_states)
52
+ elsif to_states.include?(state.name)
53
+ states_involved.merge(from_states)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ # Build diagram with all involved states
60
+ builder = Builder.new(machine, options)
61
+ builder.instance_variable_set(:@diagram, builder.send(:create_diagram))
62
+
63
+ # Add all involved states first
64
+ machine.states.select { |s| states_involved.include?(s.name) }.each do |s|
65
+ builder.send(:add_state_node, s)
66
+ end
67
+
68
+ # Then add transitions
69
+ machine.events.each do |event|
70
+ event.branches.each do |branch|
71
+ branch.state_requirements.each do |requirement|
72
+ valid_states = machine.states.by_priority.map(&:name)
73
+ from_states = requirement[:from].filter(valid_states)
74
+ to_states = requirement[:to].values
75
+
76
+ # Only add if involves our state
77
+ if (from_states & states_involved.to_a).any? && (to_states & states_involved.to_a).any?
78
+ builder.send(:create_transitions, from_states & states_involved.to_a, to_states & states_involved.to_a,
79
+ event, branch)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ output_diagram(builder.diagram, io, options.merge(state_filter: state.name.to_s), builder)
86
+ end
87
+
88
+ def diagram_hash_with_metadata(diagram, builder)
89
+ base_hash = diagram.to_h
90
+ return base_hash unless builder
91
+
92
+ transition_metadata_index = build_transition_metadata_index(builder)
93
+ transition_hashes = extract_transition_hashes(base_hash)
94
+ return base_hash unless transition_hashes
95
+
96
+ diagram.transitions.each_with_index do |transition, index|
97
+ metadata = transition_metadata_index[transition] ||
98
+ find_fallback_transition_metadata(transition_metadata_index, transition) ||
99
+ {}
100
+ transition_hash = transition_hashes[index]
101
+ next unless transition_hash
102
+
103
+ guard_terms = guard_terms_for(transition, metadata)
104
+ action_list = action_list_for(transition, metadata)
105
+
106
+ guard_payload = {}
107
+ guard_payload[:if] = guard_terms[:if] unless guard_terms[:if].empty?
108
+ guard_payload[:unless] = guard_terms[:unless] unless guard_terms[:unless].empty?
109
+ transition_hash[:guard] = guard_payload unless guard_payload.empty?
110
+
111
+ transition_hash[:action] = action_list unless action_list.empty?
112
+ end
113
+
114
+ base_hash
115
+ end
116
+
117
+ def extract_transition_hashes(base_hash)
118
+ data = base_hash[:data] || base_hash['data']
119
+ return unless data
120
+
121
+ data[:transitions] || data['transitions']
122
+ end
123
+
124
+ def draw_event(event, _graph, options = {}, io = $stdout)
125
+ machine = event.machine
126
+
127
+ # Add all states involved in this event
128
+ states_involved = Set.new
129
+ event.branches.each do |branch|
130
+ branch.state_requirements.each do |requirement|
131
+ valid_states = machine.states.by_priority.map(&:name)
132
+ from_states = requirement[:from].filter(valid_states)
133
+ to_states = requirement[:to].values
134
+
135
+ states_involved.merge(from_states)
136
+ states_involved.merge(to_states)
137
+ end
138
+ end
139
+
140
+ # Build a fresh diagram for this event
141
+ builder = Builder.new(machine, options)
142
+ builder.instance_variable_set(:@diagram, builder.send(:create_diagram))
143
+
144
+ # Add states to diagram first
145
+ machine.states.select { |s| states_involved.include?(s.name) }.each do |state|
146
+ builder.send(:add_state_node, state)
147
+ end
148
+
149
+ # Add transitions for this event
150
+ builder.send(:add_event_transitions, event)
151
+
152
+ output_diagram(builder.diagram, io, options.merge(event_filter: event.name.to_s), builder)
153
+ end
154
+
155
+ def draw_branch(branch, _graph, event, valid_states, io = $stdout)
156
+ machine = event.machine
157
+
158
+ # Add states involved in this branch
159
+ states_involved = Set.new
160
+ branch.state_requirements.each do |requirement|
161
+ from_states = requirement[:from].filter(valid_states)
162
+ to_states = requirement[:to].values
163
+
164
+ states_involved.merge(from_states)
165
+ states_involved.merge(to_states)
166
+ end
167
+
168
+ # Build fresh diagram
169
+ builder = Builder.new(machine, {})
170
+ builder.instance_variable_set(:@diagram, builder.send(:create_diagram))
171
+
172
+ # Add states first
173
+ machine.states.select { |s| states_involved.include?(s.name) }.each do |state|
174
+ builder.send(:add_state_node, state)
175
+ end
176
+
177
+ # Add transitions
178
+ builder.send(:add_branch_transitions, branch, event)
179
+
180
+ output_diagram(builder.diagram, io, {}, builder)
181
+ end
182
+
183
+ def diagram_to_text(diagram, options = {}, builder = nil)
184
+ # Defensive copy of options to prevent mutation affecting other tests
185
+ safe_options = options.dup.freeze
186
+ output = []
187
+
188
+ # Add filter headers if specified
189
+ if safe_options[:state_filter]
190
+ output << "State: #{safe_options[:state_filter]}"
191
+ output << ''
192
+ elsif safe_options[:event_filter]
193
+ output << "Event: #{safe_options[:event_filter]}"
194
+ output << ''
195
+ end
196
+
197
+ output << "=== #{diagram.title} ==="
198
+ output << ''
199
+
200
+ # Use provided builder to access metadata (with nil safety)
201
+ metadata_source = builder&.state_metadata || {}
202
+
203
+ # List states
204
+ output << 'States:'
205
+ diagram.states.each do |state|
206
+ metadata = metadata_source[state.id] || {}
207
+ type_marker = case metadata[:type]
208
+ when 'initial' then ' [*]'
209
+ when 'final' then ' (O)'
210
+ else ''
211
+ end
212
+ label = state.label || state.id
213
+ output << " - #{label}#{type_marker}"
214
+ end
215
+
216
+ output << ''
217
+ output << 'Transitions:'
218
+
219
+ # List transitions with semantic information from the transition objects
220
+ transition_metadata_index = build_transition_metadata_index(builder)
221
+
222
+ diagram.transitions.each do |transition|
223
+ metadata = transition_metadata_index[transition] ||
224
+ find_fallback_transition_metadata(transition_metadata_index, transition) ||
225
+ {}
226
+ condition_str = format_guard_condition(transition, metadata)
227
+ action_str = format_action_callback(transition, metadata)
228
+
229
+ label = transition.label || ''
230
+ output << " - #{transition.source_state_id} -> #{transition.target_state_id} [#{label}]#{condition_str}#{action_str}"
231
+ end
232
+
233
+ output.join("\n")
234
+ end
235
+
236
+ def build_transition_metadata_index(builder)
237
+ return {} unless builder&.respond_to?(:transition_metadata)
238
+
239
+ Array(builder.transition_metadata).each_with_object({}) do |metadata, index|
240
+ transition = metadata[:transition]
241
+ if transition
242
+ index[transition] = metadata
243
+ end
244
+
245
+ key = [
246
+ metadata[:from].to_s,
247
+ metadata[:to].to_s,
248
+ metadata[:event].to_s
249
+ ]
250
+ (index[:by_key] ||= Hash.new { |h, k| h[k] = [] })[key] << metadata
251
+ end
252
+ end
253
+
254
+ def find_fallback_transition_metadata(index, transition)
255
+ by_key = index[:by_key]
256
+ return unless by_key
257
+
258
+ key = [
259
+ transition.source_state_id.to_s,
260
+ transition.target_state_id.to_s,
261
+ transition.label.to_s
262
+ ]
263
+
264
+ metadata_list = by_key[key]
265
+ metadata_list&.first
266
+ end
267
+
268
+ def format_guard_condition(transition, metadata = {})
269
+ guard_terms = guard_terms_for(transition, metadata)
270
+
271
+ return '' if guard_terms[:if].empty? && guard_terms[:unless].empty?
272
+
273
+ parts = []
274
+ guard_terms[:if].each { |condition| parts << "(if: #{condition})" }
275
+ guard_terms[:unless].each { |condition| parts << "(unless: #{condition})" }
276
+ " #{parts.join(' ')}"
277
+ end
278
+
279
+ def guard_terms_for(transition, metadata = {})
280
+ guard_terms = { if: [], unless: [] }
281
+
282
+ if transition.respond_to?(:guard) && transition.guard
283
+ parse_guard_string(transition.guard.to_s, guard_terms)
284
+ end
285
+
286
+ conditions = metadata.fetch(:conditions, {})
287
+ Array(conditions[:if]).compact.each do |condition|
288
+ Array(condition).each { |item| guard_terms[:if] << normalize_condition_name(item) }
289
+ end
290
+ Array(conditions[:unless]).compact.each do |condition|
291
+ Array(condition).each { |item| guard_terms[:unless] << normalize_condition_name(item) }
292
+ end
293
+
294
+ guard_terms[:if].uniq!
295
+ guard_terms[:unless].uniq!
296
+
297
+ guard_terms
298
+ end
299
+
300
+ def parse_guard_string(guard_string, guard_terms)
301
+ guard_string.split(/\s*&&\s*/).each do |segment|
302
+ next if segment.nil? || segment.empty?
303
+
304
+ if segment.start_with?('!')
305
+ guard_terms[:unless] << normalize_condition_name(segment[1..])
306
+ else
307
+ guard_terms[:if] << normalize_condition_name(segment)
308
+ end
309
+ end
310
+ end
311
+
312
+ def normalize_condition_name(condition)
313
+ return '' if condition.nil?
314
+
315
+ condition_name = condition.to_s.strip
316
+ condition_name = condition_name[1..] if condition_name.start_with?(':')
317
+ condition_name
318
+ end
319
+
320
+ def format_action_callback(transition, metadata = {})
321
+ actions = action_list_for(transition, metadata)
322
+ return '' if actions.empty?
323
+
324
+ " (action: #{actions.join(', ')})"
325
+ end
326
+
327
+ def action_list_for(transition, metadata = {})
328
+ actions = []
329
+
330
+ if transition.respond_to?(:action) && transition.action
331
+ actions << normalize_action_name(transition.action)
332
+ end
333
+
334
+ callbacks = metadata.fetch(:callbacks, {})
335
+ callbacks.values.each do |callback_list|
336
+ Array(callback_list).each do |callback|
337
+ actions << normalize_action_name(callback)
338
+ end
339
+ end
340
+
341
+ actions.compact!
342
+ actions.uniq!
343
+ actions
344
+ end
345
+
346
+ def normalize_action_name(action)
347
+ return if action.nil?
348
+
349
+ action.is_a?(Symbol) ? action.to_s : action.to_s
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module Diagram
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/version'
5
+ require 'state_machines/diagram/builder'
6
+ require 'state_machines/diagram/renderer'
7
+
8
+ # Set the renderer to use diagram
9
+ StateMachines::Machine.renderer = StateMachines::Diagram::Renderer
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: state_machines-diagram
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: diagram
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.4
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.4
26
+ - !ruby/object:Gem::Dependency
27
+ name: state_machines
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.100'
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 0.100.4
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '0.100'
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 0.100.4
46
+ - !ruby/object:Gem::Dependency
47
+ name: bundler
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: minitest
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '5.25'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '5.25'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rake
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ description: Diagram module for state machines. Builds diagram representations of
89
+ state machines that can be rendered in various formats
90
+ email:
91
+ - terminale@gmail.com
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - LICENSE.txt
97
+ - README.md
98
+ - lib/state_machines-diagram.rb
99
+ - lib/state_machines/diagram/builder.rb
100
+ - lib/state_machines/diagram/renderer.rb
101
+ - lib/state_machines/diagram/version.rb
102
+ homepage: https://github.com/state-machines/state_machines-diagram
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ rubygems_mfa_required: 'true'
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: 3.3.0
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.6.9
122
+ specification_version: 4
123
+ summary: Diagram building for state machines
124
+ test_files: []