finite_machine 0.1.0 → 0.2.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.
@@ -8,9 +8,9 @@ Gem::Specification.new do |spec|
8
8
  spec.version = FiniteMachine::VERSION
9
9
  spec.authors = ["Piotr Murach"]
10
10
  spec.email = [""]
11
- spec.description = %q{A minimal finite state machine with a straightforward syntax.}
11
+ spec.description = %q{A minimal finite state machine with a straightforward syntax. You can quickly model states, add callbacks and use object-oriented techniques to integrate with ORMs.}
12
12
  spec.summary = %q{A minimal finite state machine with a straightforward syntax.}
13
- spec.homepage = ""
13
+ spec.homepage = "https://github.com/peter-murach/finite_machine"
14
14
  spec.license = "MIT"
15
15
 
16
16
  spec.files = `git ls-files`.split($/)
@@ -6,7 +6,9 @@ require "sync"
6
6
  require "finite_machine/version"
7
7
  require "finite_machine/threadable"
8
8
  require "finite_machine/callable"
9
+ require "finite_machine/catchable"
9
10
  require "finite_machine/event"
11
+ require "finite_machine/hooks"
10
12
  require "finite_machine/transition"
11
13
  require "finite_machine/dsl"
12
14
  require "finite_machine/state_machine"
@@ -23,6 +25,10 @@ module FiniteMachine
23
25
 
24
26
  ANY_EVENT = :any
25
27
 
28
+ ANY_STATE_HOOK = :state
29
+
30
+ ANY_EVENT_HOOK = :event
31
+
26
32
  # Returned when transition has successfully performed
27
33
  SUCCEEDED = 1
28
34
 
@@ -35,11 +41,17 @@ module FiniteMachine
35
41
  # When transition between states is invalid
36
42
  TransitionError = Class.new(::StandardError)
37
43
 
44
+ # Raised when transitining to invalid state
45
+ InvalidStateError = Class.new(::ArgumentError)
46
+
38
47
  InvalidEventError = Class.new(::NoMethodError)
39
48
 
40
49
  # Raised when a callback is defined with invalid name
41
50
  InvalidCallbackNameError = Class.new(::StandardError)
42
51
 
52
+ # Raised when event has no transitions
53
+ NotEnoughTransitionsError = Class.new(::ArgumentError)
54
+
43
55
  Environment = Struct.new(:target)
44
56
 
45
57
  # TODO: this should instantiate system not the state machine
@@ -29,8 +29,7 @@ module FiniteMachine
29
29
  # @param [Object] target
30
30
  #
31
31
  # @api public
32
- def call(env, *args, &block)
33
- target = env.target
32
+ def call(target, *args, &block)
34
33
  case object
35
34
  when Symbol
36
35
  target.__send__(@object.to_sym)
@@ -38,11 +37,7 @@ module FiniteMachine
38
37
  value = eval "lambda { #{@object} }"
39
38
  target.instance_exec(&value)
40
39
  when ::Proc
41
- if object.arity >= 1
42
- object.call(target, *args)
43
- else
44
- object.call
45
- end
40
+ object.arity.zero? ? object.call : object.call(target, *args)
46
41
  else
47
42
  raise ArgumentError, "Unknown callable #{@object}"
48
43
  end
@@ -0,0 +1,112 @@
1
+ # encoding: utf-8
2
+
3
+ module FiniteMachine
4
+
5
+ # A mixin to allow for specifying error handlers
6
+ module Catchable
7
+
8
+ def self.included(base)
9
+ base.class_eval do
10
+ attr_threadsafe :error_handlers
11
+ end
12
+ end
13
+
14
+ # Rescue exception raised in state machine
15
+ #
16
+ # @param [Array[Exception]] exceptions
17
+ #
18
+ # @example
19
+ # handle TransitionError, with: :pretty_errors
20
+ #
21
+ # @example
22
+ # handle TransitionError do |exception|
23
+ # logger.info exception.message
24
+ # raise exception
25
+ # end
26
+ #
27
+ # @api public
28
+ def handle(*exceptions, &block)
29
+ options = exceptions.last.is_a?(Hash) ? exceptions.pop : {}
30
+
31
+ unless options.key?(:with)
32
+ if block_given?
33
+ options[:with] = block
34
+ else
35
+ raise ArgumentError, 'Need to provide error handler.'
36
+ end
37
+ end
38
+ evaluate_exceptions(exceptions, options)
39
+ end
40
+
41
+ # Catches error and finds a handler
42
+ #
43
+ # @param [Exception] exception
44
+ #
45
+ # @api public
46
+ def catch_error(exception)
47
+ if handler = handler_for_error(exception)
48
+ handler.arity.zero? ? handler.call : handler.call(exception)
49
+ true
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def handler_for_error(exception)
56
+ _, handler = error_handlers.reverse.find do |class_name, _|
57
+ klass = FiniteMachine.const_get(class_name) rescue nil
58
+ klass ||= extract_const(class_name)
59
+ exception <= klass
60
+ end
61
+ evaluate_handler(handler)
62
+ end
63
+
64
+ # Find constant in state machine namespace
65
+ #
66
+ # @param [String] class_name
67
+ #
68
+ # @api private
69
+ def extract_const(class_name)
70
+ class_name.split('::').reduce(FiniteMachine) do |constant, part|
71
+ constant.const_get(part)
72
+ end
73
+ end
74
+
75
+ # Executes given handler
76
+ #
77
+ # @api private
78
+ def evaluate_handler(handler)
79
+ case handler
80
+ when Symbol
81
+ method(handler)
82
+ when Proc
83
+ if handler.arity.zero?
84
+ proc { instance_exec(&handler) }
85
+ else
86
+ proc { |_exception| instance_exec(_exception, &handler) }
87
+ end
88
+ end
89
+ end
90
+
91
+ # Check if exception inherits from Exception class and add to error handlers
92
+ #
93
+ # @param [Array[Exception]] exceptions
94
+ #
95
+ # @param [Hash] options
96
+ #
97
+ # @api private
98
+ def evaluate_exceptions(exceptions, options)
99
+ exceptions.each do |exception|
100
+ key = if exception.is_a?(Class) && exception <= Exception
101
+ exception.name
102
+ elsif exception.is_a?(String)
103
+ exception
104
+ else
105
+ raise ArgumentError, "#{exception} isn't an Exception"
106
+ end
107
+
108
+ error_handlers << [key, options[:with]]
109
+ end
110
+ end
111
+ end # Catchable
112
+ end # FiniteMachine
@@ -2,14 +2,19 @@
2
2
 
3
3
  module FiniteMachine
4
4
 
5
+ # A generic DSL for describing the state machine
5
6
  class GenericDSL
7
+ include Threadable
8
+
6
9
  class << self
7
10
  # @api private
8
11
  attr_accessor :top_level
9
12
  end
10
13
 
14
+ attr_threadsafe :machine
15
+
11
16
  def initialize(machine)
12
- @machine = machine
17
+ self.machine = machine
13
18
  end
14
19
 
15
20
  def method_missing(method_name, *args, &block)
@@ -21,23 +26,24 @@ module FiniteMachine
21
26
  end
22
27
 
23
28
  def call(&block)
24
- instance_eval(&block)
29
+ sync_exclusive { instance_eval(&block) }
25
30
  # top_level.instance_eval(&block)
26
31
  end
27
32
  end # GenericDSL
28
33
 
29
34
  class DSL < GenericDSL
30
35
 
31
- attr_reader :machine
32
-
33
- attr_reader :defer
36
+ attr_threadsafe :defer
34
37
 
35
- attr_reader :initial_event
38
+ attr_threadsafe :initial_event
36
39
 
40
+ # Initialize top level DSL
41
+ #
42
+ # @api public
37
43
  def initialize(machine)
38
44
  super(machine)
39
45
  machine.state = FiniteMachine::DEFAULT_STATE
40
- @defer = true
46
+ self.defer = true
41
47
  end
42
48
 
43
49
  # Define initial state
@@ -46,15 +52,8 @@ module FiniteMachine
46
52
  #
47
53
  # @api public
48
54
  def initial(value)
49
- if value.is_a?(String) || value.is_a?(Symbol)
50
- state, name = value, FiniteMachine::DEFAULT_EVENT_NAME
51
- @defer = false
52
- else
53
- state = value[:state]
54
- name = value.has_key?(:event) ? value[:event] : FiniteMachine::DEFAULT_EVENT_NAME
55
- @defer = value[:defer] || true
56
- end
57
- @initial_event = name
55
+ state, name, self.defer = parse(value)
56
+ self.initial_event = name
58
57
  event = proc { event name, from: FiniteMachine::DEFAULT_STATE, to: state }
59
58
  machine.events.call(&event)
60
59
  end
@@ -87,48 +86,64 @@ module FiniteMachine
87
86
  # Error handler that throws exception when machine is in illegal state
88
87
  #
89
88
  # @api public
90
- def error
89
+ def handlers(&block)
90
+ machine.errors.call(&block)
91
91
  end
92
- end # DSL
93
-
94
- class EventsDSL < GenericDSL
95
92
 
96
- attr_reader :machine
93
+ private
97
94
 
98
- def initialize(machine)
99
- super(machine)
95
+ # Parse initial options
96
+ #
97
+ # @params [String, Hash] value
98
+ #
99
+ # @return [Array[Symbol,String]]
100
+ #
101
+ # @api private
102
+ def parse(value)
103
+ if value.is_a?(String) || value.is_a?(Symbol)
104
+ [value, FiniteMachine::DEFAULT_EVENT_NAME, false]
105
+ else
106
+ [value[:state], value.fetch(:event, FiniteMachine::DEFAULT_EVENT_NAME),
107
+ !!value[:defer]]
108
+ end
100
109
  end
110
+ end # DSL
111
+
112
+ class EventsDSL < GenericDSL
101
113
 
102
114
  # Create event and associate transition
103
115
  #
116
+ # @example
117
+ # event :go, :green => :yellow
118
+ # event :go, :green => :yellow, if: :lights_on?
119
+ #
120
+ # @return [Transition]
121
+ #
104
122
  # @api public
105
123
  def event(name, attrs = {}, &block)
106
- _transition = Transition.new(machine, attrs.merge!(name: name))
107
- add_transition(_transition)
108
- define_event(_transition)
124
+ sync_exclusive do
125
+ _transition = Transition.new(machine, attrs.merge!(name: name))
126
+ _transition.define
127
+ _transition.define_event
128
+ end
109
129
  end
130
+ end # EventsDSL
110
131
 
111
- # Add transition
112
- #
113
- # @param [Transition] _transition
114
- #
115
- # @api private
116
- def add_transition(_transition)
117
- _transition.from.each do |from|
118
- machine.transitions[_transition.name][from] = _transition.to || from
119
- end
132
+ class ErrorsDSL < GenericDSL
133
+
134
+ def initialize(machine)
135
+ super(machine)
136
+ machine.error_handlers = []
120
137
  end
121
138
 
122
- # Define event
139
+ # Add error handler
123
140
  #
124
- # @param [String] name
125
- # @param [Transition] _transition
141
+ # @param [Array] exceptions
126
142
  #
127
- # @api private
128
- def define_event(_transition)
129
- machine.class.__send__ :define_method, _transition.name do |*args, &block|
130
- transition(_transition, *args, &block)
131
- end
143
+ # @api public
144
+ def handle(*exceptions, &block)
145
+ machine.handle(*exceptions, &block)
132
146
  end
133
- end # EventsDSL
147
+
148
+ end # ErrorsDSL
134
149
  end # FiniteMachine
@@ -0,0 +1,43 @@
1
+ # encoding: utf-8
2
+
3
+ module FiniteMachine
4
+
5
+ # A class reponsible for registering callbacks
6
+ class Hooks
7
+ include Threadable
8
+
9
+ attr_threadsafe :collection
10
+
11
+ # Initialize a collection of hoooks
12
+ #
13
+ # @api public
14
+ def initialize(machine)
15
+ @collection = Hash.new do |events_hash, event_type|
16
+ events_hash[event_type] = Hash.new do |state_hash, name|
17
+ state_hash[name] = []
18
+ end
19
+ end
20
+ end
21
+
22
+ # Register callback
23
+ #
24
+ # @param [String] event_type
25
+ # @param [String] name
26
+ # @param [Proc] callback
27
+ #
28
+ # @api public
29
+ def register(event_type, name, callback)
30
+ @collection[event_type][name] << callback
31
+ end
32
+
33
+ # Return all hooks matching event and state
34
+ #
35
+ # @api public
36
+ def call(event_type, event_state, event)
37
+ @collection[event_type][event_state].each do |hook|
38
+ yield hook
39
+ end
40
+ end
41
+
42
+ end # Hooks
43
+ end # FiniteMachine
@@ -18,12 +18,7 @@ module FiniteMachine
18
18
  def initialize(machine)
19
19
  @machine = machine
20
20
  @machine.subscribe(self)
21
-
22
- @hooks = Hash.new { |events_hash, event_type|
23
- events_hash[event_type] = Hash.new { |state_hash, name|
24
- state_hash[name] = []
25
- }
26
- }
21
+ @hooks = FiniteMachine::Hooks.new(machine)
27
22
  end
28
23
 
29
24
  # Evaluate in current context
@@ -41,41 +36,35 @@ module FiniteMachine
41
36
  #
42
37
  # @api public
43
38
  def on(event_type = ANY_EVENT, name = ANY_STATE, &callback)
44
- ensure_valid_callback_name!(name)
45
- hooks[event_type][name] << callback
39
+ sync_exclusive do
40
+ ensure_valid_callback_name!(name)
41
+ hooks.register event_type, name, callback
42
+ end
46
43
  end
47
44
 
48
- def on_enter(*args, &callback)
49
- if machine.states.any? { |state| state == args.first }
50
- on :enterstate, *args, &callback
51
- elsif machine.event_names.any? { |name| name == args.first }
52
- on :enteraction, *args, &callback
45
+ def listen_on(type, *args, &callback)
46
+ name = args.first
47
+ events = []
48
+ if machine.states.include?(name) || name == ANY_STATE_HOOK
49
+ events << :"#{type}state"
50
+ elsif machine.event_names.include?(name) || name == ANY_EVENT_HOOK
51
+ events << :"#{type}action"
53
52
  else
54
- on :enterstate, *args, &callback
55
- on :enteraction, *args, &callback
53
+ events << :"#{type}state" << :"#{type}action"
56
54
  end
55
+ events.each { |event| on event, *args, &callback }
56
+ end
57
+
58
+ def on_enter(*args, &callback)
59
+ listen_on :enter, *args, &callback
57
60
  end
58
61
 
59
62
  def on_transition(*args, &callback)
60
- if machine.states.any? { |state| state == args.first }
61
- on :transitionstate, *args, &callback
62
- elsif machine.event_names.any? { |name| name == args.first }
63
- on :transitionaction, *args, &callback
64
- else
65
- on :transitionstate, *args, &callback
66
- on :transitionaction, *args, &callback
67
- end
63
+ listen_on :transition, *args, &callback
68
64
  end
69
65
 
70
66
  def on_exit(*args, &callback)
71
- if machine.states.any? { |state| state == args.first }
72
- on :exitstate, *args, &callback
73
- elsif machine.event_names.any? { |name| name == args.first }
74
- on :exitaction, *args, &callback
75
- else
76
- on :exitstate, *args, &callback
77
- on :exitaction, *args, &callback
78
- end
67
+ listen_on :exit, *args, &callback
79
68
  end
80
69
 
81
70
  def method_missing(method_name, *args, &block)
@@ -94,7 +83,7 @@ module FiniteMachine
94
83
 
95
84
  TransitionEvent = Struct.new(:from, :to, :name) do
96
85
  def build(_transition)
97
- self.from = _transition.from.first
86
+ self.from = _transition.from_state
98
87
  self.to = _transition.to
99
88
  self.name = _transition.name
100
89
  end
@@ -103,14 +92,21 @@ module FiniteMachine
103
92
  def run_callback(hook, event)
104
93
  trans_event = TransitionEvent.new
105
94
  trans_event.build(event.transition)
106
- hook.call(trans_event, *event.data)
95
+ data = event.data
96
+ deferred_hook = proc { |_trans_event, *_data|
97
+ machine.instance_exec(_trans_event, *_data, &hook)
98
+ }
99
+ deferred_hook.call(trans_event, *data)
107
100
  end
108
101
 
109
- def trigger(event)
110
- [event.type, ANY_EVENT].each do |event_type|
111
- [event.state, ANY_STATE].each do |event_state|
112
- hooks[event_type][event_state].each do |hook|
113
- run_callback hook, event
102
+ def trigger(event, *args, &block)
103
+ sync_exclusive do
104
+ [event.type, ANY_EVENT].each do |event_type|
105
+ [event.state, ANY_STATE,
106
+ ANY_STATE_HOOK, ANY_EVENT_HOOK].each do |event_state|
107
+ hooks.call(event_type, event_state, event) do |hook|
108
+ run_callback(hook, event)
109
+ end
114
110
  end
115
111
  end
116
112
  end
@@ -123,12 +119,15 @@ module FiniteMachine
123
119
  @callback_names.merge machine.event_names
124
120
  @callback_names.merge machine.states
125
121
  @callback_names.merge [ANY_STATE, ANY_EVENT]
122
+ @callback_names.merge [ANY_STATE_HOOK, ANY_EVENT_HOOK]
126
123
  end
127
124
 
128
125
  def ensure_valid_callback_name!(name)
129
126
  unless callback_names.include?(name)
130
- raise InvalidCallbackNameError, "#{name} is not a valid callback name." +
131
- " Valid callback names are #{callback_names.to_a.inspect}"
127
+ exception = InvalidCallbackNameError
128
+ machine.catch_error(exception) ||
129
+ raise(InvalidCallbackNameError, "#{name} is not a valid callback name." +
130
+ " Valid callback names are #{callback_names.to_a.inspect}")
132
131
  end
133
132
  end
134
133