Skip to content

Instantly share code, notes, and snippets.

@serradura
Last active July 31, 2022 11:57
Show Gist options
  • Save serradura/173e0dfbc0fd2d7b362ed4f069a59978 to your computer and use it in GitHub Desktop.
Save serradura/173e0dfbc0fd2d7b362ed4f069a59978 to your computer and use it in GitHub Desktop.
mecha.rb - a minimalist finite state machine implemented using Ruby 3.1 (basic = 34 LOC, enhanced = 53 LOC)
class Mecha
private attr_accessor(:states_map, :callbacks, :transitions, :current_state)
public :transitions, :current_state
def initialize(initial_state:, transitions:)
self.states_map = transitions.parameters.select { |(type, _)| type == :keyreq }.to_h { [_2, _2] }
self.callbacks = Hash.new { |hash, key| hash[key] = [] }
self.transitions = transitions.call(**states_map).transform_values(&:freeze).freeze
self.current_state = states_map.fetch(initial_state)
end
def trigger?(event) = transitions.fetch(event).key?(current_state)
def trigger(event, data: nil)
return unless trigger?(event)
self.current_state =
transitions
.dig(event, current_state)
.tap { callbacks[_1].each { |callback| callback.call(event, data) } }
end
def trigger!(event, data = nil)
trigger(event, data) or fail ArgumentError.new("Invalid event :#{event} from state :#{current_state}")
end
def states = states_map.keys
def events = transitions.keys
def permitted_events = events.select { |event| trigger?(event) }
def on(event, &block) = callbacks[event].push(block) && true
def off(event) = callbacks.delete(event) && true
end
# ====================
# == Usage examples ==
# ====================
machine = Mecha.new(
initial_state: :red,
transitions: ->(red:, yellow:, green:) {
{
ready: { red => yellow },
go: { yellow => green },
stop: { green => red }
}
}
)
machine.states # [:red, :yellow, :green]
machine.events # [:ready, :go, :stop]
machine.current_state # :red
machine.permitted_events # [:ready]
machine.trigger(:ready) # :yellow
machine.current_state # :yellow
machine.permitted_events # [:go]
machine.trigger(:stop) # nil
machine.trigger?(:stop) # false
machine.trigger(:go) # :green
machine.current_state # :green
machine.permitted_events # [:stop]
machine.trigger(:stop) # :red
machine.current_state # :red
machine.on(:yellow) { puts 'The state changed to yellow.' }
machine.on(:yellow) { |event, data| puts "Received event: #{event.inspect}, along with data: #{data.inspect}." }
machine.trigger(:ready)
# The callbacks will print the following messages:
# The state changed to yellow.
# Received event: :ready, along with data: nil.
machine.trigger(:go)
machine.trigger(:stop)
machine.trigger(:ready, data: '123')
# The callbacks will print the following messages:
# The state changed to yellow.
# Received event: :ready, along with data: "123".
machine.off(:yellow)
machine.trigger(:go)
machine.trigger(:stop)
machine.trigger(:ready)
# Nothing will be printed
machine.trigger(:stop) # false
machine.trigger!(:stop) # Invalid event :stop from state :yellow (ArgumentError)
class Mecha
private attr_accessor(:_states, :restorable, :initial_state, :terminal_state)
private attr_accessor(:current_state, :transitions, :callbacks)
public :current_state, :transitions
def initialize(transitions:, initial_state: nil, terminal_state: nil, restorable: false)
self._states = transitions.parameters.select { |(type, _)| type == :keyreq }.to_h { [_2, _2] }
self.restorable = restorable
self.initial_state = _states.fetch(initial_state&.to_sym, :none)
self.terminal_state = _states.fetch(terminal_state&.to_sym, :none)
self.current_state = self.initial_state
self.transitions =
transitions
.call(**_states)
.transform_values { _1.each_with_object({}) { |(from, to), hash| Array(from).each { |key| hash[key] = to } } }
.freeze
self.callbacks = Hash.new { |hash, key| hash[key] = [] }
end
def trigger?(event) = transitions.fetch(event).key?(current_state)
def trigger(event, data: nil, callback: true)
return unless trigger?(event)
self.current_state =
transitions
.dig(event, current_state)
.tap { callbacks[_1].each { |block| block.call(event, data) } if callback }
end
def trigger!(event, data: nil, callback: true)
trigger(event, data:, callback:) or fail ArgumentError.new("Invalid event :#{event} from state :#{current_state}")
end
def events = transitions.keys
def permitted_events = events.select { |event| trigger?(event) }
def on(event, &block) = callbacks[event].push(block) && true
def off(event) = callbacks.delete(event) && true
def is?(state) = state == :none || current_state == _states.fetch(state)
def states = _states.keys
def terminated? = current_state == terminal_state
def restore!(state = nil)
new_state = state ? _states.fetch(state) : initial_state
restorable && self.current_state = new_state
end
end
# ====================
# == Usage examples ==
# ====================
machine = Mecha.new(
restorable: true,
initial_state: :red,
terminal_state: :off,
transitions: ->(red:, yellow:, green:, off:) {
{
ready: { red => yellow },
go: { yellow => green },
stop: { green => red },
turn_off: { [yellow, green] => off }
}
}
)
machine.states # [:red, :yellow, :green]
machine.events # [:ready, :go, :stop]
machine.is?(:red) # true
machine.current_state # :red
machine.permitted_events # [:ready]
machine.trigger(:ready) # :yellow
machine.current_state # :yellow
machine.permitted_events # [:go]
machine.is?(:yellow) # true
machine.is?(:red) # false
machine.trigger(:stop) # nil
machine.trigger?(:stop) # false
machine.trigger(:go) # :green
machine.current_state # :green
machine.permitted_events # [:stop]
machine.trigger(:stop) # :red
machine.current_state # :red
machine.on(:yellow) { puts 'The state changed to yellow.' }
machine.on(:yellow) { |event, data| puts "Received event: #{event.inspect}, along with data: #{data.inspect}." }
machine.trigger(:ready)
# The callbacks will print the following messages:
# The state changed to yellow.
# Received event: :ready, along with data: nil.
machine.trigger(:go)
machine.trigger(:stop)
machine.trigger(:ready, data: '123')
# The callbacks will print the following messages:
# The state changed to yellow.
# Received event: :ready, along with data: "123".
machine.restore!(:red)
machine.trigger(:ready, callback: false)
# Nothing will be printed
machine.off(:yellow)
machine.restore!(:red)
machine.trigger(:ready, callback: true)
# Nothing will be printed
machine.trigger(:turn_off)
puts '----'
puts machine.terminated?
machine.restore!(:green)
puts '----'
machine.trigger!(:turn_off)
puts machine.terminated?
puts '----'
machine.trigger(:stop) # false
machine.trigger!(:stop) # Invalid event :stop from state :yellow (ArgumentError)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment