lib/state_machine/validation.ex

defmodule StateMachine.Validation do
  @moduledoc """
  StateMachine supports automatic validation on compile time.
  It makes its best effort to ensure determinism (to some degree).
  It checks that all used states were previously defined.

  TODO: Check unreachable states?
  """

  alias StateMachine.Introspection

  def __after_compile__(env, _) do
    with errors when errors != [] <- validate_all(apply(env.module, :__state_machine__, [])) do
      raise CompileError, [file: env.file, description: Enum.join(errors, "\n")]
    end
  end

  def validate_all(sm) do
    for {:error, e} <- [
      validate_states_in_transitions(sm),
      validate_transitions_determinism(sm)
    ], do: e
    |> List.flatten()
  end

  # TODO: State uniqueness
  # TODO: Empty events

  @doc """
  Validates presense of states used in transitions.
  """
  def validate_states_in_transitions(sm) do
    states = Introspection.all_states(sm)
    errors = Enum.reduce(sm.events, [], fn {event_name, event}, acc1 ->
      Enum.reduce(event.transitions, acc1, fn transition, acc2 ->
        Map.take(transition, [:to, :from])
        |> Map.values()
        |> Enum.reduce(acc2, fn state, acc3 ->
          unless state in states do
            ["Undefined state '#{state}' is used in transition on '#{event_name}' event." | acc3]
          else
            acc3
          end
        end)
      end)
    end)

    if Enum.empty? errors do
      {:ok, sm}
    else
      {:error, Enum.reverse(errors)}
    end
  end

  @doc """
  Validates that no more than one unguarded transition is originated in every state.
  """
  def validate_transitions_determinism(sm) do
    errors = Enum.reduce(sm.events, [], fn {event_name, event}, acc1 ->
      Enum.reduce(event.transitions, {[], acc1}, fn transition, {ts, acc2} ->
        cond do
          transition.from in ts ->
            {ts, ["Event '#{event_name}' already has an unguarded transition from '#{transition.from}'; additional transition to '#{transition.to}' will never run." | acc2]}
          Enum.empty?(transition.guards) ->
            {[transition.from | ts], acc2}
          true ->
            {ts, acc2}
        end
      end) |> elem(1)
    end)

    if Enum.empty? errors do
      {:ok, sm}
    else
      {:error, Enum.reverse(errors)}
    end
  end
end