defmodule Facade do @moduledoc """ `defapi/1` defines a facade function **and** a matching callback from a single `@spec`-shaped AST. The generated function delegates to the module passed as its first argument, so every facade takes one more argument than the callback it advertises: the implementing module. This is useful when you want: - a behaviour contract (`@callback`) for implementations - a single, consistent call shape that supports dependency injection (pass the implementation module explicitly) Quick example: defmodule A do import Facade @doc "Sample documentation." defapi foo(a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer() end defmodule Impl do @behaviour A @impl A def foo(a, l) when is_integer(a) and is_list(l), do: {a, l} end # Usage: A.foo(Impl, 10, [1, 2, 3]) The spec you pass to `defapi/1` follows the same rules as `@spec`: - zero-arity can be written as `foo :: atom()` or `foo() :: atom()` - guards are supported via `when` - attributes like `@doc`, `@doc false`, and `@deprecated` on the call site are copied onto the generated function """ @type missing_callbacks :: [{atom(), non_neg_integer()}] @type validate_result :: :ok | {:error, missing_callbacks()} defmodule MissingCallbacksError do @moduledoc """ Raised by `validate!/2` (and `validate!/1` when `use Facade` is used in a behaviour module) when a module is missing required callbacks. """ defexception [:behaviour, :module, :missing_callbacks] @type t :: %__MODULE__{ behaviour: module(), module: module(), missing_callbacks: Facade.missing_callbacks() } @impl Exception def message(%__MODULE__{behaviour: behaviour, module: module, missing_callbacks: missing}) do formatted = missing |> Enum.map(fn {name, arity} -> "#{name}/#{arity}" end) |> Enum.join(", ") "module #{inspect(module)} does not implement behaviour #{inspect(behaviour)}; missing callbacks: #{formatted}" end end @doc """ When used from a behaviour module, imports `defapi/1` and defines: - `validate/1` — validates an implementation module at runtime - `validate!/1` — like `validate/1`, but raises `#{inspect(MissingCallbacksError)}` """ defmacro __using__(_opts) do quote do import Facade, only: [defapi: 1] @doc "Validates that `impl_module` implements the required callbacks of this behaviour." @spec validate(module()) :: Facade.validate_result() def validate(impl_module), do: Facade.validate(__MODULE__, impl_module) @doc "Like `validate/1`, but raises if required callbacks are missing." @spec validate!(module()) :: :ok def validate!(impl_module), do: Facade.validate!(__MODULE__, impl_module) end end @doc """ Validates that `impl_module` implements required callbacks of `behaviour_module`. Optional callbacks (declared via `@optional_callbacks`) are ignored. """ @spec validate(module(), module()) :: validate_result() def validate(behaviour_module, impl_module) do required = required_callbacks!(behaviour_module) missing = case Code.ensure_loaded(impl_module) do {:module, _} -> Enum.reject(required, fn {name, arity} -> function_exported?(impl_module, name, arity) end) {:error, _reason} -> required end if missing == [], do: :ok, else: {:error, missing} end @doc """ Like `validate/2`, but raises `#{inspect(MissingCallbacksError)}` on failure. """ @spec validate!(module(), module()) :: :ok | no_return() def validate!(behaviour_module, impl_module) do case validate(behaviour_module, impl_module) do :ok -> :ok {:error, missing_callbacks} -> raise MissingCallbacksError, behaviour: behaviour_module, module: impl_module, missing_callbacks: missing_callbacks end end @doc """ Defines a facade function and a callback with the given specification. The generated function will take an extra first argument: the module implementing the callback, and inherits caller attributes such as `@doc`, `@doc false`, and `@deprecated`. ## Examples defapi foo(a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer() Generates: @spec foo(mod :: module(), a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer() def foo(mod, a, l) do mod.foo(a, l) end @callback foo(a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer() Zero-arity forms also work: defapi ping :: :pong """ defmacro defapi(spec_ast) do {name, args_sig_ast, ret_ast, guards_ast} = split_cb!(spec_ast) {vars, vars_spec} = build_args(args_sig_ast) mod_spec = {:"::", [], [{:mod, [], nil}, {:module, [], []}]} spec = {:"::", [], [{name, [], [mod_spec | vars_spec]}, ret_ast]} spec = case guards_ast do nil -> spec ast -> {:when, [], [spec, ast]} end quote do @spec unquote(spec) def unquote(name)(mod, unquote_splicing(vars)) do mod.unquote(name)(unquote_splicing(vars)) end @callback unquote(spec_ast) end end defp split_cb!(ast) do {core, guards} = case ast do {:when, _, [inner, guards]} -> {inner, guards} other -> {other, nil} end case core do {:"::", _, [head, ret]} -> case head do {name, _, args} when is_atom(name) -> {name, args, ret, guards} _ -> raise ArgumentError, "defapi expects `name(args...) :: return` (e.g. `foo(a :: integer()) :: atom()`), got: #{Macro.to_string(ast)}" end _ -> raise ArgumentError, "defapi expects `name(args...) :: return` (e.g. `foo(a :: integer()) :: atom()`), got: #{Macro.to_string(ast)}" end end defp required_callbacks!(behaviour_module) do unless function_exported?(behaviour_module, :behaviour_info, 1) do raise ArgumentError, "expected #{inspect(behaviour_module)} to be a behaviour module (must define @callback and export behaviour_info/1)" end callbacks = behaviour_module.behaviour_info(:callbacks) optional = behaviour_module.behaviour_info(:optional_callbacks) callbacks -- optional end defp build_args(arg_asts) do pairs = arg_asts |> List.wrap() |> Enum.with_index(1) |> Enum.map(fn {arg_ast, idx} -> normalize_arg(arg_ast, idx) end) arg_vars = Enum.map(pairs, fn {var, _typed} -> var end) arg_specs = Enum.map(pairs, fn {_var, typed} -> typed end) {arg_vars, arg_specs} end defp normalize_arg({:"::", _, [var_ast, _]} = typed, _idx) do var = case var_ast do {name, meta, ctx} when is_atom(name) and is_list(meta) -> {name, meta, ctx} _ -> # typed but not a variable on LHS, treat as unnamed Macro.var(:arg, nil) end {var, typed} end defp normalize_arg({name, meta, ctx} = var_ast, _idx) when is_atom(name) and is_list(meta) and (is_atom(ctx) or is_nil(ctx)) do {var_ast, var_ast} end defp normalize_arg(other, idx) do # unnamed type like integer() or some AST: generate argN variable var = Macro.var(:"arg#{idx}", nil) {var, other} end end