Current section

Files

Jump to
double_down lib double_down facade codegen.ex
Raw

lib/double_down/facade/codegen.ex

defmodule DoubleDown.Facade.Codegen do
@moduledoc false
# Shared code generation helpers used by both `DoubleDown.ContractFacade`
# (defcallback contracts) and `DoubleDown.ContractFacade.Behaviour`
# (vanilla @behaviour modules).
#
# All public functions in this module are called at compile time
# from `__using__` or `__before_compile__` macros and return
# quoted AST or plain values.
# -------------------------------------------------------------------
# Dispatch option resolution
# -------------------------------------------------------------------
@doc false
@spec resolve_dispatch_option(term(), Macro.Env.t(), boolean()) :: boolean()
def resolve_dispatch_option(value, caller_env, default) do
case value do
nil ->
default
bool when is_boolean(bool) ->
bool
fun when is_function(fun, 0) ->
fun.()
ast ->
# Function literal passed as an option arrives as AST in the macro.
# Evaluate it at compile time in the caller's context.
{fun, _binding} = Code.eval_quoted(ast, [], caller_env)
fun.()
end
end
# -------------------------------------------------------------------
# Static impl resolution
# -------------------------------------------------------------------
@doc false
@spec resolve_static_impl(atom(), atom(), boolean(), boolean()) :: module() | nil
def resolve_static_impl(otp_app, contract, test_dispatch?, static_dispatch?) do
if !test_dispatch? and static_dispatch? do
case Application.get_env(otp_app, contract) do
nil ->
nil
config when is_list(config) ->
Keyword.get(config, :impl)
impl when is_atom(impl) ->
impl
end
else
nil
end
end
# -------------------------------------------------------------------
# Code Generation: facade functions
# -------------------------------------------------------------------
@doc false
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def generate_facade(
%{
name: name,
params: param_names,
param_types: param_types,
return_type: return_type,
pre_dispatch: pre_dispatch,
user_doc: user_doc
} = operation,
contract,
otp_app,
test_dispatch?,
static_impl
) do
# when_constraints is present for vanilla behaviour specs with `when` clauses.
# defcallback operations don't have this field (always nil).
when_constraints = Map.get(operation, :when_constraints)
param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end)
doc_ast =
if user_doc do
{_line, doc_content} = user_doc
quote do
@doc unquote(doc_content)
end
else
doc_string =
"Port operation: `#{name}/#{length(param_names)}`\n\nDispatches to the configured implementation via `DoubleDown.Contract.Dispatch`.\n"
quote do
@doc unquote(doc_string)
end
end
# param_types and return_type are AST tuples from __callbacks__/0.
# We splice them directly — unquote treats 3-tuples as AST.
#
# When a pre_dispatch function is declared on a defcallback, it is applied
# to the args list before dispatch. The function receives (args, facade_module)
# and returns the (possibly modified) args list. The pre_dispatch value
# is AST (double-escaped through __callbacks__/0) and is spliced
# directly into the generated function body.
dispatch_args =
if pre_dispatch do
quote do
unquote(pre_dispatch).(unquote(param_vars), __MODULE__)
end
else
param_vars
end
spec_ast = build_spec_ast(name, param_types, return_type, when_constraints)
# Three dispatch paths, selected at compile time:
#
# 1. test_dispatch? true -> DoubleDown.Contract.Dispatch.call/4
# (NimbleOwnership test handlers + config fallback)
#
# 2. static_impl set -> apply(impl, operation, args)
# (direct call, zero overhead — impl resolved at compile time)
#
# 3. otherwise -> DoubleDown.Contract.Dispatch.call_config/4
# (runtime Application.get_env lookup)
cond do
test_dispatch? ->
quote do
unquote(doc_ast)
@spec unquote(spec_ast)
def unquote(name)(unquote_splicing(param_vars)) do
DoubleDown.Contract.Dispatch.call(
unquote(otp_app),
unquote(contract),
unquote(name),
unquote(dispatch_args)
)
end
end
static_impl != nil && pre_dispatch == nil ->
# Direct call + inline — zero dispatch overhead.
# Only possible when there's no pre_dispatch transform.
quote do
unquote(doc_ast)
@compile {:inline, [{unquote(name), unquote(length(param_names))}]}
@spec unquote(spec_ast)
def unquote(name)(unquote_splicing(param_vars)) do
unquote(static_impl).unquote(name)(unquote_splicing(param_vars))
end
end
static_impl != nil ->
# Static impl with pre_dispatch — can't inline because args
# are transformed at runtime.
quote do
unquote(doc_ast)
@spec unquote(spec_ast)
def unquote(name)(unquote_splicing(param_vars)) do
apply(unquote(static_impl), unquote(name), unquote(dispatch_args))
end
end
true ->
quote do
unquote(doc_ast)
@spec unquote(spec_ast)
def unquote(name)(unquote_splicing(param_vars)) do
DoubleDown.Contract.Dispatch.call_config(
unquote(otp_app),
unquote(contract),
unquote(name),
unquote(dispatch_args)
)
end
end
end
end
# -------------------------------------------------------------------
# Code Generation: key helpers
# -------------------------------------------------------------------
@doc false
def generate_key_helper(%{name: name, params: param_names}, contract) do
param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end)
doc_string =
"Build a test stub key for the `#{name}` port operation.\n"
quote do
@doc unquote(doc_string)
def __key__(unquote(name), unquote_splicing(param_vars)) do
DoubleDown.Contract.Dispatch.key(unquote(contract), unquote(name), unquote(param_vars))
end
end
end
# -------------------------------------------------------------------
# Code Generation: @spec AST
# -------------------------------------------------------------------
# Build a @spec AST, optionally including `when` constraints for
# vanilla behaviour specs with bounded type variables.
#
# Without constraints: name(param_types) :: return_type
# With constraints: name(param_types) :: return_type when var: type, ...
defp build_spec_ast(name, param_types, return_type, nil) do
quote do
unquote(name)(unquote_splicing(param_types)) :: unquote(return_type)
end
end
defp build_spec_ast(name, param_types, return_type, when_constraints) do
base_spec =
quote do
unquote(name)(unquote_splicing(param_types)) :: unquote(return_type)
end
{:when, [], [base_spec, when_constraints]}
end
# -------------------------------------------------------------------
# Code Generation: moduledoc
# -------------------------------------------------------------------
@doc false
def generate_moduledoc(contract, otp_app, existing_moduledoc \\ nil) do
generated =
"""
Dispatch facade for `#{inspect(contract)}`.
Dispatches calls to the configured implementation via
`DoubleDown.Contract.Dispatch`. In production, resolves from application
config (`#{inspect(otp_app)}`). In tests, resolves
from `DoubleDown.Testing` handlers.\
"""
combined =
case existing_moduledoc do
# User wrote @moduledoc false — respect it, suppress all docs
{_, false} ->
false
# User provided a moduledoc — prepend it, append generated info
{_, user_doc} when is_binary(user_doc) ->
String.trim_trailing(user_doc) <> "\n\n---\n\n" <> generated
# No user moduledoc — use generated only
_ ->
generated
end
quote do
@moduledoc unquote(combined)
end
end
end