forked from guard/listen
/
fsm.rb
130 lines (108 loc) · 4.22 KB
/
fsm.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# frozen_string_literal: true
# Code copied from https://github.com/celluloid/celluloid-fsm
require 'thread'
module Listen
module FSM
# Included hook to extend class methods
def self.included(klass)
klass.send :extend, ClassMethods
end
module ClassMethods
# Obtain or set the start state
# Passing a state name sets the start state
def start_state(new_start_state = nil)
if new_start_state
new_start_state.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{new_start_state.inspect})"
@start_state = new_start_state
else
defined?(@start_state) or raise ArgumentError, "`start_state :<state>` must be declared before `new`"
@start_state
end
end
# The valid states for this FSM, as a hash with state name symbols as keys and State objects as values.
def states
@states ||= {}
end
# Declare an FSM state and optionally provide a callback block to fire on state entry
# Options:
# * to: a state or array of states this state can transition to
def state(state_name, to: nil, &block)
state_name.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{state_name.inspect})"
states[state_name] = State.new(state_name, to, &block)
end
end
# Note: including classes must call initialize_fsm from their initialize method.
def initialize_fsm
@fsm_initialized = true
@state = self.class.start_state
@mutex = ::Mutex.new
@state_changed = ::ConditionVariable.new
end
# Current state of the FSM, stored as a symbol
attr_reader :state
# checks for one of the given states to wait for
# if not already, waits for a state change (up to timeout seconds--`nil` means infinite)
# returns truthy iff the transition to one of the desired state has occurred
def wait_for_state(*wait_for_states, timeout: nil)
@mutex.synchronize do
if !wait_for_states.include?(@state)
@state_changed.wait(@mutex, timeout)
end
wait_for_states.include?(@state)
end
end
private
def transition(new_state_name)
new_state_name.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{new_state_name.inspect})"
if (new_state = validate_and_sanitize_new_state(new_state_name))
transition_with_callbacks!(new_state)
end
end
# Low-level, immediate state transition with no checks or callbacks.
def transition!(new_state_name)
new_state_name.is_a?(Symbol) or raise ArgumentError, "state name must be a Symbol (got #{new_state_name.inspect})"
@fsm_initialized or raise ArgumentError, "FSM not initialized. You must call initialize_fsm from initialize!"
@mutex.synchronize do
yield if block_given?
@state = new_state_name
@state_changed.broadcast
end
end
def validate_and_sanitize_new_state(new_state_name)
return nil if @state == new_state_name
if current_state && !current_state.valid_transition?(new_state_name)
valid = current_state.transitions.map(&:to_s).join(', ')
msg = "#{self.class} can't change state from '#{@state}' to '#{new_state_name}', only to: #{valid}"
raise ArgumentError, msg
end
unless (new_state = self.class.states[new_state_name])
new_state_name == self.class.start_state or raise ArgumentError, "invalid state for #{self.class}: #{new_state_name}"
end
new_state
end
def transition_with_callbacks!(new_state)
transition! new_state.name
new_state.call(self)
end
def current_state
self.class.states[@state]
end
class State
attr_reader :name, :transitions
def initialize(name, transitions, &block)
@name = name
@block = block
@transitions = if transitions
Array(transitions).map(&:to_sym)
end
end
def call(obj)
obj.instance_eval(&@block) if @block
end
def valid_transition?(new_state)
# All transitions are allowed if none are expressly declared
!@transitions || @transitions.include?(new_state.to_sym)
end
end
end
end