lib/expression/v2/callbacks.ex

defmodule Expression.V2.Callbacks do
  @moduledoc """
  Use this module to implement one's own callbacks.
  The standard callbacks available are implemented in `Expression.V2.Callbacks.Standard`.

  ```elixir
  defmodule MyCallbacks do
    use Expression.V2.Callbacks

    @doc \"\"\"
    Roll a dice and randomly return a number between 1 and 6.
    \"\"\"
    def dice_roll(ctx) do
      Enum.random(1..6)
    end

  end
  ```
  """

  alias Expression.V2.Callbacks.Standard

  @reserved_words ~w[and if or not]

  @doc """
  Convert a string function name into an atom meant to handle
  that function

  Reserved words such as `and`, `if`, and `or` are automatically suffixed
  with an `_` underscore.
  """
  def atom_function_name(function_name) when function_name in @reserved_words,
    do: atom_function_name("#{function_name}_")

  def atom_function_name(function_name) do
    String.to_atom(function_name)
  end

  @doc """
  Callback a function while evaluating the context against an expression.

  Callback functions in this module are either:

  1. The function name as is
  2. The function name with an underscore suffix if the function name is a reserved word
  3. The function name suffixed with `_vargs` if the takes a variable set of arguments
  """
  @spec callback(
          module :: module,
          context :: map,
          function_name :: binary,
          arguments :: [any]
        ) :: any
  def callback(module \\ Standard, context, function_name, arguments) do
    case implements(module, function_name, arguments) do
      {:exact, module, function_name} ->
        apply(
          module,
          function_name,
          [context] ++ Enum.map(arguments, &Expression.V2.default_value(&1, context))
        )

      {:vargs, module, function_name} ->
        apply(module, function_name, [
          context,
          Enum.map(arguments, &Expression.V2.default_value(&1, context))
        ])

      {:error, reason} ->
        reason
    end
  end

  @spec implements(module, function_name :: String.t(), arguments :: [any]) ::
          {:exact, module, function_name :: atom}
          | {:vargs, module, function_name :: atom}
          | {:error, reason :: String.t()}
  def implements(module \\ Standard, function_name, arguments) do
    exact_function_name = atom_function_name(function_name)
    vargs_function_name = atom_function_name("#{function_name}_vargs")

    Code.ensure_loaded!(module)

    cond do
      function_exported?(module, exact_function_name, length(arguments) + 1) ->
        {:exact, module, exact_function_name}

      # Check if it's been implemented to accept a variable amount of arguments
      function_exported?(module, vargs_function_name, 2) ->
        {:vargs, module, vargs_function_name}

      # Check if the exact function signature has been implemented
      function_exported?(Standard, exact_function_name, length(arguments) + 1) ->
        {:exact, Standard, exact_function_name}

      # Check if it's been implemented to accept a variable amount of arguments
      function_exported?(Standard, vargs_function_name, 2) ->
        {:vargs, Standard, vargs_function_name}

      # Otherwise fail
      true ->
        {:error, "#{function_name} is not implemented."}
    end
  end

  defmacro __using__(_opts) do
    quote do
      def callback(module \\ __MODULE__, context, function_name, args)

      defdelegate callback(module, context, function_name, arguments),
        to: Expression.V2.Callbacks
    end
  end
end