lib/coerce.ex

defmodule Coerce do
  @moduledoc """
  Coerce allows defining coercions between data types.

  These are standardized conversions of one kind of data to another.
  A coercion can be defined using `defcoercion`.

  The code that coercion is compiled to attempts to ensure that the result
  is relatively fast (with the possibility for further optimization in the future).

  Coerce does _not_ come with built-in coercions, instead allowing libraries that build on top of it
  to define their own rules.
  """

  @builtin_guards_list [
    is_tuple: Tuple,
    is_atom: Atom,
    is_list: List,
    is_map: Map,
    is_bitstring: BitString,
    is_integer: Integer,
    is_float: Float,
    is_function: Function,
    is_pid: PID,
    is_port: Port,
    is_reference: Reference]

  @doc """
  Performs value coercion,

  the simpler of the two values is converted into
  a more complex type, and the result is returned as tuple.


  ## Examples

      iex> require Coerce
      iex> Coerce.defcoercion(Integer, Float) do
      iex>   def coerce(int, float) do
      iex>     {int + 0.0, float}
      iex>   end
      iex> end
      iex> Coerce.coerce(1, 2.3)
      {1.0, 2.3}
      iex> Coerce.coerce(1.4, 42)
      {1.4, 42.0}


      iex> require Coerce
      iex> Coerce.defcoercion(BitString, Atom) do
      iex>   def coerce(str, atom) do
      iex>     {str, inspect(atom)}
      iex>   end
      iex> end
      iex> Coerce.coerce("foo", Bar)
      {"foo", "Bar"}
      iex> Coerce.coerce("baz", :qux)
      {"baz", ":qux"}

  """
  @spec coerce(a, b) :: {a, a} | {b, b} when a: any, b: any
  def coerce(a, b)

  def coerce(a = %a_mod{}, b = %a_mod{}) do
    {a, b}
  end
  def coerce(a = %a_mod{}, b = %b_mod{}) do
    Module.concat([Coerce.Implementations, a_mod, b_mod]).coerce(a, b)
  end

  for {guard, mod} <- @builtin_guards_list do
    def coerce(a = %a_struct_mod{}, b) when unquote(guard)(b) do
      Module.concat([Coerce.Implementations, a_struct_mod, unquote(mod)]).coerce(a, b)
    end

    def coerce(a, b = %b_struct_mod{}) when unquote(guard)(a) do
      Module.concat([Coerce.Implementations, unquote(mod), b_struct_mod]).coerce(a, b)
    end
  end

  for {guard_a, a_mod} <- @builtin_guards_list, {guard_b, b_mod} <- @builtin_guards_list do
    if guard_a == guard_b do
      def coerce(a, b) when unquote(guard_a)(a) and unquote(guard_a)(b) do
        {a, b}
      end

    else
      primary_module = Module.concat([Coerce.Implementations, a_mod, b_mod])
      def coerce(a, b) when unquote(guard_a)(a) and unquote(guard_b)(b) do
        # Uses Kernel.apply to mitigate warning if module does not exist...
        apply(unquote(primary_module), :coerce, [a, b])
      end
    end
  end

  @doc """
  Define a coercion between two data types.

  Expects two module names as the first two arguments and a `do`-block as third argument.
  A `Coerc.CompileError` will be raised at compile-time if the coercion macro is called improperly.

  """
  defmacro defcoercion(first_module, second_module, [do: block]) do
    first_module = Macro.expand_once(first_module, __CALLER__)
    second_module = Macro.expand_once(second_module, __CALLER__)

    unless is_atom(first_module) && is_atom(second_module) do
      raise Coerce.CompileError, "`Coerce.defcoercion` called with improper arguments. Expects #{inspect(first_module)} and #{inspect(second_module)} to be module names."
    end
    primary_module = Module.concat([Coerce.Implementations, first_module, second_module])
    secondary_module = Module.concat([Coerce.Implementations, second_module, first_module])

    quote do
      defmodule unquote(primary_module) do
        @moduledoc false
        unquote(block)
      end

      Code.ensure_compiled!(unquote(primary_module))

      unless function_exported?(unquote(primary_module), :coerce, 2) do
        raise Coerce.CompileError, "`Coerce.defcoercion` implementation does not implement `coerce/2`."
      end

      defmodule unquote(secondary_module) do
        @moduledoc false
        def coerce(lhs, rhs) do
          {rhs, lhs} = unquote(primary_module).coerce(rhs, lhs)
          {lhs, rhs}
        end
      end
    end
  end
end