lib/ex_up.ex

defmodule ExUp do
  @moduledoc """
  Documentation for `ExUp`.
  """

  @doc ~S"""
  Defines a function or a deprecated function that delegates to mod. Depending on the Elixir version.

  The first argument is the elixir version since this function is available.
  The `mod` argument is the original elixir module where the function is implemented.
  The `fun` argument is the original elixir function name.
  `args` is a list of arguments that the generated function will accept.
  `body` is the implementation older versions of elixir will use, it should do the same of the
  original function.

  ## Examples

      iex> defmodule Sample1 do
      ...>   import ExUp
      ...>   defup("1.0.0", List, :first, [list, default \\ nil]) do
      ...>     case list do
      ...>       [] -> default
      ...>       [first | _] -> first
      ...>     end
      ...>   end
      ...> end
      iex> Sample1.first([])
      nil
      iex> Sample1.first([1, 2])
      1
  """
  @doc since: "0.1.0"
  defmacro defup(elixir_since, mod, fun, args \\ [], do: body)
           when is_binary(elixir_since) and is_atom(fun) and is_list(args) do
    args = [args] |> List.flatten() |> Enum.map(&Macro.escape/1)
    body = Macro.escape(body, unquote: true)

    current_version_has_support? = Version.match?(System.version(), ">= #{elixir_since}")
    arity = length(args)
    module = Macro.prewalk(mod, &Macro.expand(&1, __ENV__))

    validate_function!(
      module,
      fun,
      arity,
      elixir_since,
      current_version_has_support?,
      __CALLER__.file,
      __CALLER__.line
    )

    mfa = Exception.format_mfa(module, fun, arity)
    doc = "Upwards compatibility to `#{mfa}` @ `#{elixir_since}`"
    # FIXME: auto link to correct version?
    if current_version_has_support? do
      quote bind_quoted: [
              mfa: mfa,
              doc: doc,
              fun: fun,
              args: args,
              mod: mod,
              body: body
            ] do
        if Mix.env() != :test do
          @deprecated "Use #{mfa} directly, as we're already on #{System.version()}"
        end

        @doc doc
        defdelegate unquote(fun)(unquote_splicing(args)), to: mod

        @doc false
        if Mix.env() != :test do
          @deprecated "Use #{mfa} directly, as we're already on #{System.version()}"
        end

        def unquote(String.to_atom("__#{fun}__"))(unquote_splicing(args)), do: unquote(body)
      end
    else
      quote bind_quoted: [
              doc: doc,
              fun: fun,
              args: args,
              body: body
            ] do
        @doc doc
        def unquote(fun)(unquote_splicing(args)), do: unquote(body)
      end
    end
  end

  defp validate_function!(
         module,
         fun,
         expected_arity,
         elixir_since,
         current_version_has_support?,
         caller_file,
         caller_line
       ) do
    {arity, since} = function_info(module, fun)

    if current_version_has_support? && expected_arity != arity do
      raise_undefined_function(module, fun, expected_arity, caller_file, caller_line)
    end

    if current_version_has_support? && since != nil && since != elixir_since do
      raise CompileError.exception(
              description:
                "function #{Exception.format_mfa(module, fun, arity)} is defined since '#{since}' not '#{elixir_since}'",
              file: caller_file,
              line: caller_line
            )
    end
  end

  defp function_info(module, fun) do
    case Code.fetch_docs(module) do
      {:docs_v1, _, _, _, _, _, docs} ->
        Enum.find_value(docs, nil, fn doc ->
          case doc do
            {{_, ^fun, arity}, _, _, _, docs} -> {arity, Map.get(docs, :since)}
            _ -> nil
          end
        end) || {nil, nil}
    end
  end

  defp raise_undefined_function(module, fun, expected_arity, caller_file, caller_line) do
    error = %UndefinedFunctionError{
      module: Code.ensure_loaded!(module),
      function: fun,
      arity: expected_arity
    }

    {%{message: description}, _} = UndefinedFunctionError.blame(error, nil)

    raise CompileError.exception(
            description: description,
            file: caller_file,
            line: caller_line
          )
  end
end