lib/states_language.ex

defmodule StatesLanguage do
  @moduledoc """
  A macro to parse [StatesLanguage](https://states-language.net/spec.html) JSON and create :gen_statem modules
  """
  alias StatesLanguage.{AST, Graph, SchemaLoader}
  alias Xema.Validator
  require Logger

  @schema :states_language
          |> :code.priv_dir()
          |> Path.join("schemas/states_language.json")
          |> File.read!()
          |> Jason.decode!()
          |> JsonXema.new(loader: SchemaLoader)

  @typedoc """
  All callbacks are expected to return a tuple containing an updated (if necessary) `t:StatesLanguage.t/0`,
  and a list of actions to perform after the callback has exectuted.
  A default callback that doesn't need to do anything would just return {:ok, data, []}
  """
  @type callback_result :: {:ok, t(), [:gen_statem.action()] | :gen_statem.action() | []}

  @typedoc """
  When using the "Parallel" or "Map" types, the children processes must `send/2` a message to the parent process of this type. The `child_process` pid is the `t:pid/0` of the child spawned by the parent state machine, generally the same as calling `self/0` in the child process itself.
  """
  @type task_processed :: {:task_processed, result :: any(), child_process :: pid()}

  @doc """
  Called when a Choice or Task state is transitioned to.
  ## Arguments
  - resource: The value of the `Resource` field for this state
  - params: the data after applying any JSONPath selectors to our data attribute
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_resource(
              resource :: String.t(),
              params :: term(),
              state :: String.t(),
              data :: t()
            ) :: callback_result()

  @doc """
  Called when something has sent an event to our process.
  ## Arguments
  - event: the event that was sent to us
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_info(event :: term(), state :: String.t(), data :: t()) :: callback_result()

  @doc """
  Called when a transition event has been received, but before we transition to the next state.
  ## Arguments
  - event: The transition event received
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_transition(event :: term(), state :: String.t(), data :: t()) ::
              callback_result()

  @doc """
  Called when we enter a new state, but before any additional actions have occurred.
  ## Arguments
  - old_state: The previous state we were in
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_enter(old_state :: String.t(), state :: String.t(), data :: t()) ::
              callback_result()

  @doc """
  Called when a call event has been received. It is up to your implentation to return {:reply, from, result} to send the result back to the caller.
  ## Arguments
  - event: the payload sent with the call
  - from: used to reply to the caller
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_call(
              event :: term(),
              from :: GenServer.from(),
              state :: String.t(),
              data :: t()
            ) :: callback_result()

  @doc """
  Called when a cast event has been received.
  ## Arguments
  - event: the payload sent with the cast
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_cast(event :: term(), state :: String.t(), data :: t()) ::
              callback_result()

  @doc """
  Called when a process is ending. This can be because it was killed or a state indicated it's the end of the state machine. Used for cleanup.
  ## Arguments
  - reason: the reason we are ending eg; `:normal`, `:kill`, etc.
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_termination(reason :: term(), state :: String.t(), data :: t()) :: :ok

  @doc """
  Called when a [Generic Timeout](http://erlang.org/doc/man/gen_statem.html#type-generic_timeout) is triggered.
  ## Arguments
  - event: The event set for the timeout
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_generic_timeout(event :: term(), state :: String.t(), data :: t()) ::
              callback_result()

  @doc """
  Called when a [State Timeout](http://erlang.org/doc/man/gen_statem.html#type-state_timeout) is triggered.
  ## Arguments
  - event: The event set for the timeout
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_state_timeout(event :: term(), state :: String.t(), data :: t()) ::
              callback_result()

  @doc """
  Called when a [Event Timeout](http://erlang.org/doc/man/gen_statem.html#type-event_timeout) is triggered.
  ## Arguments
  - event: The event set for the timeout
  - state: the current state
  - data: the full data of the `:gen_statem` process
  """
  @callback handle_event_timeout(event :: term(), state :: String.t(), data :: t()) ::
              callback_result()

  @optional_callbacks handle_resource: 4,
                      handle_call: 4,
                      handle_cast: 3,
                      handle_info: 3,
                      handle_enter: 3,
                      handle_transition: 3,
                      handle_termination: 3,
                      handle_generic_timeout: 3,
                      handle_state_timeout: 3,
                      handle_event_timeout: 3

  defmodule Edge do
    @moduledoc """
    Represents a transition from one state to another
    """
    defstruct [:source, :target, :event]

    @type t :: %__MODULE__{
            source: String.t(),
            target: String.t(),
            event: Macro.input()
          }
  end

  defmodule Choice do
    @moduledoc """
    Represents a choice option in a Choice type state
    """
    defstruct [:string_equals, :next]

    @type t :: %__MODULE__{
            string_equals: Macro.input(),
            next: String.t()
          }
  end

  defmodule Catch do
    @moduledoc """
    Represents a catch error in a Task type state
    """
    defstruct [:error_equals, :next]

    @type t :: %__MODULE__{
            error_equals: [Macro.input()],
            next: String.t()
          }
  end

  defmodule Node do
    @moduledoc """
    Represents any state in our graph 
    """
    defstruct [
      :type,
      :default,
      :next,
      :iterator,
      :items_path,
      :catch,
      :choices,
      :branches,
      :seconds,
      :timestamp,
      :seconds_path,
      :timestamp_path,
      :resource,
      :parameters,
      :input_path,
      :resource_path,
      :output_path,
      :event,
      :is_end
    ]

    @type t :: %__MODULE__{
            type: String.t(),
            default: String.t() | nil,
            next: String.t() | nil,
            iterator: String.t() | nil,
            items_path: String.t() | nil,
            branches: [String.t()] | nil,
            seconds: float() | integer() | nil,
            timestamp: DateTime.t() | nil,
            seconds_path: String.t() | nil,
            timestamp_path: String.t() | nil,
            catch: [Catch.t()] | [],
            choices: [Choice.t()] | [],
            resource: String.t() | nil,
            parameters: %{},
            input_path: String.t() | nil,
            resource_path: String.t() | nil,
            output_path: String.t() | nil,
            event: [any()] | nil,
            is_end: boolean()
          }
  end

  @derive Jason.Encoder

  defstruct [:_parent, :_parent_data, :data, _tasks: []]

  @typedoc """
  Passed to all processes as the data for the [:gen_statem.data](http://erlang.org/doc/man/gen_statem.html#type-data).

  - `_parent` is used to reference a parent process within a `Map` or `Parallel` state type
  - `_parent_data` is the data from the parent
  - `data` is the data passed to this process on startup
  - `_tasks` is used to keep track of child processes for `Map` and `Parallel` state types
  """
  @type t :: %__MODULE__{_parent: pid(), _parent_data: any(), data: any(), _tasks: []}

  defmacro __using__(data: data) when is_map(data) do
    case validate(data) do
      {:ok, data} -> do_start(data)
      {:error, _} = error -> throw(error)
    end
  end

  defmacro __using__(data: data) when is_binary(data) do
    data
    |> File.read!()
    |> Jason.decode!()
    |> validate()
    |> case do
      {:ok, data} -> do_start(data)
      {:error, _} = error -> throw(error)
    end
  end

  @spec do_start(map()) :: [any()]
  defp do_start(data, path \\ nil) when is_map(data) do
    %Graph{} = graph = Graph.serialize(data)

    AST.external_resource(path) ++
      AST.default(graph) ++ AST.start(graph) ++ AST.graph(graph) ++ AST.catch_all()
  end

  @doc """
  Validates our graph data against the included JSON Schema. This is run automatically at compilation time.
  """
  @spec validate(map()) :: {:ok, map()} | {:error, Validator.result()}
  def validate(data) do
    case JsonXema.validate(@schema, data) do
      :ok ->
        {:ok, data}

      error ->
        {:error, error}
    end
  end
end