Current section

Files

Jump to
double_down lib double_down facade behaviour_introspection.ex
Raw

lib/double_down/facade/behaviour_introspection.ex

defmodule DoubleDown.Facade.BehaviourIntrospection do
@moduledoc false
# Extracts operation metadata from vanilla Elixir `@behaviour` modules
# by reading their `@callback` declarations via `Code.Typespec`.
#
# Returns operation maps in the same format as `DoubleDown.Contract.__callbacks__/0`,
# suitable for passing to `DoubleDown.Facade.Codegen.generate_facade/5`.
#
# Note: `Code.Typespec` is `@moduledoc false` in Elixir's stdlib but has
# been stable for years and is used by IEx, dialyxir, hammox, and many
# other tools. It is the only way to get callback type information from
# compiled BEAM files.
@doc false
@spec fetch_operations!(module(), Macro.Env.t()) :: [map()]
def fetch_operations!(behaviour, env) do
validate_loaded!(behaviour, env)
callbacks = fetch_callbacks!(behaviour, env)
Enum.map(callbacks, fn {{name, _arity}, spec_clauses} ->
# Use the first spec clause. Multiple clauses arise from
# overloaded @callback declarations for the same name/arity,
# which is rare but valid Elixir.
[first_clause | _rest] = spec_clauses
quoted_spec = Code.Typespec.spec_to_quoted(name, first_clause)
{param_types, return_type, when_constraints} = destructure_spec(quoted_spec)
param_names = extract_param_names(param_types)
bare_param_types = strip_annotations(param_types)
%{
name: name,
params: param_names,
param_types: bare_param_types,
return_type: return_type,
when_constraints: when_constraints,
pre_dispatch: nil,
user_doc: nil,
arity: length(param_names)
}
end)
end
# -------------------------------------------------------------------
# Validation
# -------------------------------------------------------------------
defp validate_loaded!(behaviour, env) do
unless Code.ensure_loaded?(behaviour) do
raise CompileError,
description:
"Behaviour module #{inspect(behaviour)} is not loaded. " <>
"Ensure it is compiled before #{inspect(env.module)}.",
file: env.file,
line: 0
end
end
defp fetch_callbacks!(behaviour, env) do
# Code.Typespec.fetch_callbacks/1 accepts a module atom (looks for
# .beam on disk) or a raw beam binary. Try the module atom first,
# fall back to :code.get_object_code/1 for the in-memory binary.
#
# Important: the behaviour module's .beam file must be on disk for
# this to work. If both modules are in the same compilation unit
# (e.g. same elixirc_paths directory), the .beam won't be written
# yet. The behaviour must be in a directory that compiles before
# the facade. See the @moduledoc for details.
result =
case Code.Typespec.fetch_callbacks(behaviour) do
{:ok, callbacks} ->
{:ok, callbacks}
:error ->
case :code.get_object_code(behaviour) do
{^behaviour, binary, _path} ->
Code.Typespec.fetch_callbacks(binary)
:error ->
:error
end
end
case result do
{:ok, []} ->
raise CompileError,
description:
"#{inspect(behaviour)} has no @callback declarations. " <>
"Cannot generate a facade for a module with no callbacks.",
file: env.file,
line: 0
{:ok, callbacks} ->
callbacks
:error ->
raise CompileError,
description:
"Could not fetch callback specs from #{inspect(behaviour)}. " <>
"Ensure it defines @callback declarations and that its .beam " <>
"file is on disk (the behaviour must compile in a prior " <>
"compilation unit — see DoubleDown.BehaviourFacade docs).",
file: env.file,
line: 0
end
end
# -------------------------------------------------------------------
# Spec destructuring
# -------------------------------------------------------------------
# Spec with `when` clause:
# {:when, _, [{:"::", _, [{:name, _, params}, return]}, constraints]}
# Returns {param_types, return_type, constraints} where constraints
# is a keyword list like [input: {:term, _, []}, output: {:term, _, []}].
defp destructure_spec({:when, _meta, [{:"::", _meta2, [call, return_type]}, constraints]}) do
param_types = extract_call_params(call)
{param_types, return_type, constraints}
end
# Normal spec:
# {:"::", _, [{:name, _, params}, return]}
defp destructure_spec({:"::", _meta, [call, return_type]}) do
param_types = extract_call_params(call)
{param_types, return_type, nil}
end
# Extract params from the function call part of the spec.
# Zero-arg: {:fun_name, meta, []}
# With args: {:fun_name, meta, [param_type1, param_type2, ...]}
defp extract_call_params({_name, _meta, params}) when is_list(params), do: params
defp extract_call_params({_name, _meta, nil}), do: []
# -------------------------------------------------------------------
# Param name extraction
# -------------------------------------------------------------------
# Extract param names from param type ASTs.
#
# Three shapes we handle:
# 1. Annotated params like `id :: String.t()`:
# {:"::", _, [{:id, _, nil}, type_ast]}
# → use the annotation name `:id`
#
# 2. Type variables from `when` clauses like `transform(input) :: ...`:
# {:input, _, nil}
# → use the variable name `:input`
# (Distinguished from bare types by nil context vs [] args)
#
# 3. Bare types like `String.t()` or `map()`:
# {{:., _, [String, :t]}, _, []} or {:map, _, []}
# → synthesize `arg1`, `arg2`, etc.
defp extract_param_names(param_types) do
param_types
|> Enum.with_index(1)
|> Enum.map(fn {param_type, index} ->
case param_type do
# Annotated: id :: String.t()
{:"::", _meta, [{name, _name_meta, nil}, _type]} when is_atom(name) ->
name
# Type variable from when clause: {:input, _, nil}
{name, _meta, nil} when is_atom(name) ->
name
# Bare type: synthesize name
_bare_type ->
String.to_atom("arg#{index}")
end
end)
end
# -------------------------------------------------------------------
# Annotation stripping
# -------------------------------------------------------------------
# Strip name annotations from param types.
# Annotated: {:"::", _, [{:name, _, nil}, actual_type]} -> actual_type
# Bare: already just the type, pass through.
defp strip_annotations(param_types) do
Enum.map(param_types, fn
{:"::", _meta, [{_name, _name_meta, nil}, type]} ->
type
bare_type ->
bare_type
end)
end
end