piotrmurach/finite_machine

View on GitHub
lib/finite_machine/state_machine.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require "forwardable"

require_relative "catchable"
require_relative "dsl"
require_relative "env"
require_relative "events_map"
require_relative "hook_event"
require_relative "observer"
require_relative "threadable"
require_relative "subscribers"

module FiniteMachine
  # Base class for state machine
  class StateMachine
    include Threadable
    include Catchable
    extend Forwardable

    # Current state
    #
    # @return [Symbol]
    #
    # @api private
    attr_threadsafe :state

    # Initial state, defaults to :none
    attr_threadsafe :initial_state

    # Final state, defaults to nil
    attr_threadsafe :terminal_states

    # The prefix used to name events.
    attr_threadsafe :namespace

    # The state machine environment
    attr_threadsafe :env

    # The state machine event definitions
    attr_threadsafe :events_map

    # Machine dsl
    #
    # @return [DSL]
    #
    # @api private
    attr_threadsafe :dsl

    # The state machine observer
    #
    # @return [Observer]
    #
    # @api private
    attr_threadsafe :observer

    # The state machine subscribers
    #
    # @return [Subscribers]
    #
    # @api private
    attr_threadsafe :subscribers

    # Allow or not logging of transitions
    attr_threadsafe :log_transitions

    def_delegators :dsl, :initial, :terminal, :event, :trigger_init,
                   :alias_target

    # Initialize state machine
    #
    # @example
    #   fsm = FiniteMachine::StateMachine.new(target_alias: :car) do
    #     initial :red
    #
    #     event :go, :red => :green
    #
    #     on_transition do |event|
    #       car.state = event.to
    #     end
    #   end
    #
    # @param [Hash] options
    #   the options to create state machine with
    # @option options [String] :alias_target
    #   the alias for target object
    #
    # @api private
    def initialize(*args, &block)
      options = args.last.is_a?(::Hash) ? args.pop : {}
      @initial_state = DEFAULT_STATE
      @auto_methods  = options.fetch(:auto_methods, true)
      @subscribers   = Subscribers.new
      @observer      = Observer.new(self)
      @events_map    = EventsMap.new
      @env           = Env.new(self, [])
      @dsl           = DSL.new(self, options)

      env.target = args.pop unless args.empty?
      env.aliases << options[:alias_target] if options[:alias_target]
      dsl.call(&block) if block_given?
      trigger_init
    end

    # Check if event methods should be auto generated
    #
    # @return [Boolean]
    #
    # @api public
    def auto_methods?
      @auto_methods
    end

    # Attach state machine to an object
    #
    # This allows state machine to initiate events in the context
    # of a particular object
    #
    # @example
    #   FiniteMachine.define(target: object) do
    #     ...
    #   end
    #
    # @return [Object|FiniteMachine::StateMachine]
    #
    # @api public
    def target
      env.target
    end

    # Subscribe observer for event notifications
    #
    # @example
    #   machine.subscribe(Observer.new(machine))
    #
    # @api public
    def subscribe(*observers)
      sync_exclusive { subscribers.subscribe(*observers) }
    end

    # Get current state
    #
    # @return [String]
    #
    # @api public
    def current
      sync_shared { state }
    end

    # Check if current state matches provided state
    #
    # @example
    #   fsm.is?(:green) # => true
    #
    # @param [String, Array[String]] state
    #
    # @return [Boolean]
    #
    # @api public
    def is?(state)
      if state.is_a?(Array)
        state.include? current
      else
        state == current
      end
    end

    # Retrieve all states
    #
    # @example
    #  fsm.states # => [:yellow, :green, :red]
    #
    # @return [Array[Symbol]]
    #
    # @api public
    def states
      sync_shared { events_map.states }
    end

    # Retireve all event names
    #
    # @example
    #   fsm.events # => [:init, :start, :stop]
    #
    # @return [Array[Symbol]]
    #
    # @api public
    def events
      events_map.events
    end

    # Checks if event can be triggered
    #
    # @example
    #   fsm.can?(:go) # => true
    #
    # @example
    #   fsm.can?(:go, "Piotr")  # checks condition with parameter "Piotr"
    #
    # @param [String] event
    #
    # @return [Boolean]
    #
    # @api public
    def can?(*args)
      event_name = args.shift
      events_map.can_perform?(event_name, current, *args)
    end

    # Checks if event cannot be triggered
    #
    # @example
    #   fsm.cannot?(:go) # => false
    #
    # @param [String] event
    #
    # @return [Boolean]
    #
    # @api public
    def cannot?(*args, &block)
      !can?(*args, &block)
    end

    # Checks if terminal state has been reached
    #
    # @return [Boolean]
    #
    # @api public
    def terminated?
      is?(terminal_states)
    end

    # Restore this machine to a known state
    #
    # @param [Symbol] state
    #
    # @return nil
    #
    # @api public
    def restore!(state)
      sync_exclusive { self.state = state }
    end

    # Check if state is reachable
    #
    # @param [Symbol] event_name
    #   the event name for all transitions
    #
    # @return [Boolean]
    #
    # @api private
    def valid_state?(event_name)
      current_states = events_map.states_for(event_name)
      current_states.any? { |state| state == current || state == ANY_STATE }
    end

    # Notify about event all the subscribers
    #
    # @param [HookEvent] :hook_event_type
    #   The hook event type.
    # @param [FiniteMachine::Transition] :event_transition
    #   The event transition.
    # @param [Array[Object]] :data
    #   The data associated with the hook event.
    #
    # @return [nil]
    #
    # @api private
    def notify(hook_event_type, event_name, from, *data)
      sync_shared do
        hook_event = hook_event_type.build(current, event_name, from)
        subscribers.visit(hook_event, *data)
      end
    end

    # Attempt performing event trigger for valid state
    #
    # @return [Boolean]
    #   true is trigger successful, false otherwise
    #
    # @api private
    def try_trigger(event_name)
      if valid_state?(event_name)
        yield
      else
        exception = InvalidStateError
        catch_error(exception) ||
          raise(exception, "inappropriate current state '#{current}'")

        false
      end
    end

    # Trigger transition event with data
    #
    # @param [Symbol] event_name
    #   the event name
    # @param [Array] data
    #
    # @return [Boolean]
    #   true when transition is successful, false otherwise
    #
    # @api public
    def trigger!(event_name, *data, &block)
      from = current # Save away current state

      sync_exclusive do
        notify HookEvent::Before, event_name, from, *data

        status = try_trigger(event_name) do
          if can?(event_name, *data)
            notify HookEvent::Exit, event_name, from, *data

            stat = transition!(event_name, *data, &block)

            notify HookEvent::Transition, event_name, from, *data
            notify HookEvent::Enter, event_name, from, *data
          else
            stat = false
          end
          stat
        end

        notify HookEvent::After, event_name, from, *data

        status
      end
    rescue Exception => err
      self.state = from # rollback transition
      raise err
    end

    # Trigger transition event without raising any errors
    #
    # @param [Symbol] event_name
    #
    # @return [Boolean]
    #   true on successful transition, false otherwise
    #
    # @api public
    def trigger(event_name, *data, &block)
      trigger!(event_name, *data, &block)
    rescue InvalidStateError, TransitionError, CallbackError
      false
    end

    # Find available state to transition to and transition
    #
    # @param [Symbol] event_name
    #
    # @api private
    def transition!(event_name, *data, &block)
      from_state = current
      to_state   = events_map.move_to(event_name, from_state, *data)

      block.call(from_state, to_state) if block

      if log_transitions
        Logger.report_transition(event_name, from_state, to_state, *data)
      end

      try_trigger(event_name) { transition_to!(to_state) }
    end

    def transition(event_name, *data, &block)
      transition!(event_name, *data, &block)
    rescue InvalidStateError, TransitionError
      false
    end

    # Update this state machine state to new one
    #
    # @param [Symbol] new_state
    #
    # @raise [TransitionError]
    #
    # @api private
    def transition_to!(new_state)
      from_state = current
      self.state = new_state
      self.initial_state = new_state if from_state == DEFAULT_STATE
      true
    rescue Exception => e
      catch_error(e) || raise_transition_error(e)
    end

    # String representation of this machine
    #
    # @return [String]
    #
    # @api public
    def inspect
      sync_shared do
        "<##{self.class}:0x#{object_id.to_s(16)} " \
        "@current=#{current.inspect} " \
        "@states=#{states} " \
        "@events=#{events} " \
        "@transitions=#{events_map.state_transitions}>"
      end
    end

    private

    # Raise when failed to transition between states
    #
    # @param [Exception] error
    #   the error to describe
    #
    # @raise [TransitionError]
    #
    # @api private
    def raise_transition_error(error)
      raise TransitionError, Logger.format_error(error)
    end

    # Forward the message to observer or self
    #
    # @param [String] method_name
    #
    # @param [Array] args
    #
    # @return [self]
    #
    # @api private
    def method_missing(method_name, *args, &block)
      if observer.respond_to?(method_name.to_sym)
        observer.public_send(method_name.to_sym, *args, &block)
      elsif env.aliases.include?(method_name.to_sym)
        env.send(:target, *args, &block)
      else
        super
      end
    end

    # Test if a message can be handled by state machine
    #
    # @param [String] method_name
    #
    # @param [Boolean] include_private
    #
    # @return [Boolean]
    #
    # @api private
    def respond_to_missing?(method_name, include_private = false)
      observer.respond_to?(method_name.to_sym) ||
        env.aliases.include?(method_name.to_sym) || super
    end
  end # StateMachine
end # FiniteMachine