lib/state_machine/dsl.ex

defmodule StateMachine.DSL do
  @moduledoc """
  These macros help generating State Machine definition in a given Module.
  """

  alias StateMachine.{State, Event, Transition, Context, Guard, Introspection}
  import StateMachine.Utils, only: [keyword_splat: 2]

  @doc """
  Creates a main block for a State Machine definition. Compile time checks and validations help
  ensuring correct usage of this and other macros.

      use StateMachine

      defmachine field: :custom do
        # ... states, events, transitions
      end

  ## Options
    * `:field` - what field in the model structure holds the state value. Default: `:state`.
    * `:repo` - the Ecto Repo module. If provider, the support for Ecto is activated, including
      state getter/setters, Ecto.Type generation and custom `trigger` function.
    * `:state_type` - name for `Ecto.Type` implementation for State type. Generated inside of the module namespace.
      If you state machine is `App.StateMachine`, then `Ecto.Type` implementation will be
      accessible on `App.StateMachine.(state_type)`. Default: `StateType`.
    * `:event_type` - name for `Ecto.Type` implementation for Event type. Default: `EventType`.
  """
  defmacro defmachine(opts \\ [], block) do
    head =
      quote do
        if Keyword.has_key?(unquote(opts), :ecto_type) do
          raise CompileError, [
            file: __ENV__.file,
            line: __ENV__.line,
            description: "Option `ecto_type` is deprecated, use `state_type` and `event_type` to generate EctoType implementations respectively"
          ]
        end

        @after_compile StateMachine.Validation
        Module.register_attribute(__MODULE__, :states, accumulate: true)
        Module.register_attribute(__MODULE__, :events, accumulate: true)
        Module.put_attribute(__MODULE__, :in_defmachine, true)
        Module.put_attribute(__MODULE__, :field, Keyword.get(unquote(opts), :field, :state))
        Module.put_attribute(__MODULE__, :repo, Keyword.get(unquote(opts), :repo))
        Module.put_attribute(__MODULE__, :state_type, Keyword.get(unquote(opts), :state_type, StateType))
        Module.put_attribute(__MODULE__, :event_type, Keyword.get(unquote(opts), :event_type, EventType))
        unquote(block)
      end

    out =
      quote unquote: false do
        state_names = Enum.map(@states, & &1.name)
        Module.put_attribute(__MODULE__, :state_names, state_names)

        event_names = Enum.map(@events, & &1.name)
        Module.put_attribute(__MODULE__, :event_names, event_names)

        states = @states |> Enum.reverse |> Enum.reduce(%{}, fn state, acc ->
          Map.put(acc, state.name, state)
        end)

        events = @events |> Enum.reverse |> Enum.reduce(%{}, fn event, acc ->
          Map.put(acc, event.name, event)
        end)

        field = @field

        getter = if @repo, do: &StateMachine.Ecto.get/1, else: &State.get/1
        setter = if @repo, do: &StateMachine.Ecto.set/2, else: &State.set/2

        misc = if @repo, do: [repo: @repo], else: []

        Module.delete_attribute(__MODULE__, :states)
        Module.delete_attribute(__MODULE__, :events)
        Module.delete_attribute(__MODULE__, :field)
        Module.delete_attribute(__MODULE__, :in_defmachine)

        def __state_machine__, do: %StateMachine{
          field: unquote(field),
          states: unquote(Macro.escape(states)),
          events: unquote(Macro.escape(events)),
          state_getter: unquote(Macro.escape(getter)),
          state_setter: unquote(Macro.escape(setter)),
          misc: unquote(Macro.escape(misc))
        }

        introspection_functions()
        if @repo do
          require StateMachine.Ecto
          StateMachine.Ecto.define_ecto_type(:state)
          StateMachine.Ecto.define_ecto_type(:event)

          ecto_action_functions()
        else
          action_functions()
        end

        unless Enum.empty?(state_names) do
          @type state :: unquote(Enum.reduce(state_names, &{:|, [], [&1, &2]}))
        end

        unless Enum.empty?(event_names) do
          @type event :: unquote(Enum.reduce(event_names, &{:|, [], [&1, &2]}))
        end

        Module.delete_attribute(__MODULE__, :repo)
        Module.delete_attribute(__MODULE__, :state_type)
        Module.delete_attribute(__MODULE__, :event_type)
        Module.delete_attribute(__MODULE__, :state_names)
        Module.delete_attribute(__MODULE__, :event_names)
      end

    quote do
      unquote(head)
      unquote(out)
    end
  end

  @doc """
  Creates a State record with any atom as a name.
  Supports defining callbacks for before/after leaving/entering given state.
  For detailed description of Callbacks, see the module documentation,
  but currently only fully qualified function capture is supported: `&Module.fun/arity`

      defmachine do
        state :sleeping, after_leave: &Kitchen.brew_coffee/1
        state :working, before_enter: [&Commute.drive/1, &Commute.grab_a_newspaper/1]
      end

  ## Options
    * `:before_leave` - run the callback before leaving this state.
    * `:after_leave` - run the callback after leaving this state.
    * `:before_enter` - run the callback before entering this state.
    * `:after_enter` - run the callback after entering this state.
  """
  defmacro state(name, opts \\ []) when is_atom(name) do
    quote do
      unless Module.get_attribute(__MODULE__, :in_defmachine) do
        raise CompileError, [file: __ENV__.file, line: __ENV__.line, description: "Calling `state` outside of state machine definition"]
      end
      @states %State{
        name: unquote(Macro.escape(name)),
        before_leave: keyword_splat(unquote(opts), :before_leave),
        after_leave:  keyword_splat(unquote(opts), :after_leave),
        before_enter: keyword_splat(unquote(opts), :before_enter),
        after_enter:  keyword_splat(unquote(opts), :after_enter)
      }
    end
  end

  @doc """
  Creates an Event record which encapsulates one or more Transitions.
  Conceptually an Event is an external signal to change the state.
  Events can be accompanied by Guards — additional conditions, allowing
  for implementing more complex logic than supported by theoretical
  state machines.

  Using guards allow for transitions from one to the multiple states,
  based on condition.


      defmachine do
        # for state defs, see `state`

        event :wake_up, if: &Bedroom.slept_eight_hours?/1 do
          # for transition defs, see `transition`
        end
      end

  ## Options
    * `:before` - run the callback before the event.
    * `:after` - run the callback after the event.
    * `:if` - positive guard, must return `true` to proceed.
    * `:unless` - negative guard, must return `false` to proceed.
  """
  defmacro event(name, opts \\ [], block) when is_atom(name) do
    head =
      quote do
        unless Module.get_attribute(__MODULE__, :in_defmachine) do
          raise CompileError, [file: __ENV__.file, line: __ENV__.line, description: "Calling `event` outside of state machine definition"]
        end

        Module.register_attribute(__MODULE__, :transitions, accumulate: true)
        Module.put_attribute(__MODULE__, :in_event, true)
        unquote(block)
      end

    transitions =
      quote unquote: false do
        transitions = @transitions |> Enum.reverse
        Module.delete_attribute(__MODULE__, :transitions)
        transitions
      end

    out =
      quote do
        Module.delete_attribute(__MODULE__, :in_event)
        @events %Event{
          name: unquote(name),
          transitions: unquote(transitions),
          before: keyword_splat(unquote(opts), :before),
          after:  keyword_splat(unquote(opts), :after),
          guards: Guard.prepare(unquote(opts))
        }
      end

    quote do
      unquote(head)
      unquote(out)
    end
  end

  @doc """
  Defines a Transition, a path from one or more states to a single state.
  The same Event can contain all crazy combinations of transitions,
  but the resolution happens from top to bottom, from left to right.
  First matching transition will run.


      defmachine do
        # for state defs, see `state`

        event :wake_up do
          transition from: :sleeping, to: :sleeping,
            if: &Calendar.weekend?/0,
            unless: &Bedroom.slept_ten_hours?/1
          transition from: :sleeping, to: :awake
        end
      end

  ## Options
    * `:before` - run the callback before the transition.
    * `:after` - run the callback after the transition.
    * `:if` - positive guard, must return `true` to proceed.
    * `:unless` - negative guard, must return `false` to proceed.
  """
  defmacro transition(opts) do
    quote do
      unless Module.get_attribute(__MODULE__, :in_event) do
        raise CompileError, [file: __ENV__.file, line: __ENV__.line, description: "Calling `transition` outside of `event` block"]
      end

      Enum.each(StateMachine.Utils.keyword_splat(unquote(opts), :from), fn from ->
        @transitions %Transition{
          from: from,
          to:   Keyword.get(unquote(opts), :to),
          before: keyword_splat(unquote(opts), :before),
          after:  keyword_splat(unquote(opts), :after),
          guards: Guard.prepare(unquote(opts))
        }
      end)
    end
  end

  @doc false
  defmacro introspection_functions do
    quote do
      def all_states do
        Introspection.all_states(__state_machine__())
      end

      def all_events do
        Introspection.all_events(__state_machine__())
      end

      def allowed_events(model) do
        Context.build(__state_machine__(), model)
        |> Introspection.allowed_events()
      end

      def trigger_with_context(model, event, payload \\ nil) do
        Context.build(__state_machine__(), model)
        |> Event.trigger(event, payload)
      end
    end
  end

  @doc false
  defmacro action_functions do
    quote do
      def trigger(model, event, payload \\ nil) do
        case trigger_with_context(model, event, payload) do
          %Context{status: :done, model: model} -> {:ok, model}
          %Context{error: m} -> {:error, m}
        end
      end
    end
  end

  @doc false
  defmacro ecto_action_functions do
    quote do
      def trigger(model, event, payload \\ nil) do
        repo = __state_machine__().misc[:repo]
        repo.transaction fn ->
          case trigger_with_context(model, event, payload) do
            %Context{status: :done, model: model} -> model
            %Context{error: m} -> repo.rollback(m)
          end
        end
      end
    end
  end

  @doc """
  Experimental macro to generate GenStatem definition. See source...
  """
  defmacro define_gen_statem do
    quote do
      def start_link(model) do
        :gen_statem.start_link(__MODULE__, model, [])
      end

      def trigger_cast(sm_pid, event, payload \\ nil) do
        :gen_statem.cast(sm_pid, {event, payload})
      end

      # TODO: opts?
      def trigger_call(sm_pid, event, payload \\ nil) do
        :gen_statem.call(sm_pid, {event, payload})
      end

      def init(model) do
        context = Context.build(__state_machine__(), model)
        {:ok, context.definition.state_getter.(context), model}
      end

      def handle_event(kind, {event, payload}, state, model) do
        case __MODULE__.trigger(model, event, payload) do
          {:ok, new_model} ->
            context = Context.build(__state_machine__(), new_model)
            new_state = context.definition.state_getter.(context)
            {:next_state, new_state, new_model, actions(kind, {:ok, new_model})}
          {:error, e} ->
            {:next_state, state, model, actions(kind, {:error, e})}
        end
      end

      def callback_mode do
        :handle_event_function
      end

      defp actions(:cast, _) do
        []
      end

      defp actions({:call, from}, resp) do
        [{:reply, from, resp}]
      end
    end
  end
end