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