piotrmurach/finite_machine

View on GitHub
lib/finite_machine/observer.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require "securerandom"

require_relative "async_call"
require_relative "callable"
require_relative "hook_event"
require_relative "hooks"
require_relative "message_queue"
require_relative "safety"
require_relative "transition_event"

module FiniteMachine
  # A class responsible for observing state changes
  class Observer < GenericDSL
    include Safety

    # The current state machine
    attr_reader :machine

    # The hooks to trigger around the transition lifecycle.
    attr_reader :hooks

    # Initialize an Observer
    #
    # @param [StateMachine] machine
    #   reference to the current machine
    #
    # @api public
    def initialize(machine)
      @machine = machine
      @hooks   = Hooks.new

      @machine.subscribe(self)
    end

    # Evaluate in current context
    #
    # @api private
    def call(&block)
      instance_eval(&block)
    end

    # Register callback for a given hook type
    #
    # @param [HookEvent] hook_type
    # @param [Symbol] state_or_event_name
    # @param [Proc] callback
    #
    # @example
    #   observer.on HookEvent::Enter, :green
    #
    # @api public
    def on(hook_type, state_or_event_name = nil, async = nil, &callback)
      sync_exclusive do
        if state_or_event_name.nil?
          state_or_event_name = HookEvent.any_state_or_event(hook_type)
        end
        async = false if async.nil?
        ensure_valid_callback_name!(hook_type, state_or_event_name)
        callback.extend(Async) if async == :async
        hooks.register(hook_type, state_or_event_name, callback)
      end
    end

    # Unregister callback for a given event
    #
    # @api public
    def off(hook_type, name = ANY_STATE, &callback)
      sync_exclusive do
        hooks.unregister hook_type, name, callback
      end
    end

    module Once; end

    module Async; end

    def on_enter(*args, &callback)
      on HookEvent::Enter, *args, &callback
    end

    def on_transition(*args, &callback)
      on HookEvent::Transition, *args, &callback
    end

    def on_exit(*args, &callback)
      on HookEvent::Exit, *args, &callback
    end

    def once_on_enter(*args, &callback)
      on HookEvent::Enter, *args, &callback.extend(Once)
    end

    def once_on_transition(*args, &callback)
      on HookEvent::Transition, *args, &callback.extend(Once)
    end

    def once_on_exit(*args, &callback)
      on HookEvent::Exit, *args, &callback.extend(Once)
    end

    def on_before(*args, &callback)
      on HookEvent::Before, *args, &callback
    end

    def on_after(*args, &callback)
      on HookEvent::After, *args, &callback
    end

    def once_on_before(*args, &callback)
      on HookEvent::Before, *args, &callback.extend(Once)
    end

    def once_on_after(*args, &callback)
      on HookEvent::After, *args, &callback.extend(Once)
    end

    # Execute each of the hooks in order with supplied data
    #
    # @param [HookEvent] event
    #   the hook event
    #
    # @param [Array[Object]] data
    #
    # @return [nil]
    #
    # @api public
    def emit(event, *data)
      sync_exclusive do
        [event.type].each do |hook_type|
          any_state_or_event = HookEvent.any_state_or_event(hook_type)
          [any_state_or_event, event.name].each do |event_name|
            hooks[hook_type][event_name].each do |hook|
              handle_callback(hook, event, *data)
              off(hook_type, event_name, &hook) if hook.is_a?(Once)
            end
          end
        end
      end
    end

    # Cancel the current event
    #
    # This should be called inside a on_before or on_exit callbacks
    # to prevent event transition.
    #
    # @param [String] msg
    #   the message used for failure
    #
    # @api public
    def cancel_event(msg = nil)
      raise CallbackError.new(msg)
    end

    private

    # Handle callback and decide if run synchronously or asynchronously
    #
    # @param [Proc] :hook
    #   The hook to evaluate
    #
    # @param [HookEvent] :event
    #   The event for which the hook is called
    #
    # @param [Array[Object]] :data
    #
    # @api private
    def handle_callback(hook, event, *data)
      to = machine.events_map.move_to(event.event_name, event.from, *data)
      trans_event = TransitionEvent.new(event.event_name, event.from, to)
      callable    = create_callable(hook)

      if hook.is_a?(Async)
        defer(callable, trans_event, *data)
      else
        callable.(trans_event, *data)
      end
    end

    # Defer callback execution
    #
    # @api private
    def defer(callable, trans_event, *data)
      async_call = AsyncCall.new(machine, callable, trans_event, *data)
      callback_queue.start unless callback_queue.running?
      callback_queue << async_call
    end

    # Get an existing callback queue or create a new one
    #
    # @return [FiniteMachine::MessageQueue]
    #
    # @api private
    def callback_queue
      @callback_queue ||= MessageQueue.new.tap do
        @queue_id = SecureRandom.uuid
        ObjectSpace.define_finalizer(@queue_id, proc do
          cleanup_callback_queue
        end)
      end
    end

    # Clean up the callback queue
    #
    # @return [Boolean, nil]
    #
    # @api private
    def cleanup_callback_queue
      ObjectSpace.undefine_finalizer(@queue_id) if @queue_id
      return unless @callback_queue && callback_queue.alive?

      begin
        callback_queue.shutdown
      rescue MessageQueueDeadError
      end
    end

    # Create callable instance
    #
    # @api private
    def create_callable(hook)
      callback = proc do |trans_event, *data|
        machine.instance_exec(trans_event, *data, &hook)
      end
      Callable.new(callback)
    end

    # Callback names including all states and events
    #
    # @return [Array[Symbol]]
    #   valid callback names
    #
    # @api private
    def callback_names
      machine.states + machine.events + [ANY_EVENT, ANY_STATE]
    end

    # Forward the message to observer
    #
    # @param [String] method_name
    #
    # @param [Array] args
    #
    # @return [self]
    #
    # @api private
    def method_missing(method_name, *args, &block)
      _, event_name, callback_name = *method_name.to_s.match(/^(\w*?on_\w+?)_(\w+)$/)
      if callback_name && callback_names.include?(callback_name.to_sym)
        public_send(event_name, :"#{callback_name}", *args, &block)
      else
        super
      end
    end

    # Test if a message can be handled by observer
    #
    # @param [String] method_name
    #
    # @param [Boolean] include_private
    #
    # @return [Boolean]
    #
    # @api private
    def respond_to_missing?(method_name, include_private = false)
      *_, callback_name = *method_name.to_s.match(/^(\w*?on_\w+?)_(\w+)$/)
      callback_name && callback_names.include?(:"#{callback_name}")
    end
  end # Observer
end # FiniteMachine