lib/expression/callbacks.ex

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

  ```elixir
  defmodule MyCallbacks do
    use Expression.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.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 """
  Handle a function call while evaluating the AST.

  Handlers 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 handle(module :: module, function_name :: binary, arguments :: [any], context :: map) ::
          {:ok, any} | {:error, :not_implemented}
  def handle(module \\ Standard, function_name, arguments, context) do
    case implements(module, function_name, arguments) do
      {:exact, module, function_name, _arity} ->
        {:ok, apply(module, function_name, [context] ++ arguments)}

      {:vargs, module, function_name, _arity} ->
        {:ok, apply(module, function_name, [context, arguments])}

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

  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!(Standard)

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

      # 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, 2}

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

      # 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, 2}

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

  defmacro __using__(_opts) do
    quote do
      import Expression.Callbacks.EvalHelpers

      defdelegate handle(module \\ __MODULE__, function_name, arguments, context),
        to: Expression.Callbacks
    end
  end
end