File: fsm.rb

package info (click to toggle)
ruby-listen 3.9.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 544 kB
  • sloc: ruby: 5,033; makefile: 9
file content (133 lines) | stat: -rw-r--r-- 4,437 bytes parent folder | download | duplicates (2)
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
131
132
133
# 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)
      wait_for_states.each do |state|
        state.is_a?(Symbol) or raise ArgumentError, "states must be symbols (got #{state.inspect})"
      end
      @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