Current section

Files

Jump to
bond lib bond compiler invariants.ex
Raw

lib/bond/compiler/invariants.ex

defmodule Bond.Compiler.Invariants do
@moduledoc internal: true
@moduledoc """
Emission logic for `@invariant`s.
Lives in its own module (rather than inside `Bond.Compiler.AnnotatedFunction`) for two
reasons:
1. **Separation of concerns.** `AnnotatedFunction` owns the data model — clauses,
per-function assertions, doc attributes, the override decision. The invariant emission
is its own concern: detecting the struct-bearing argument, building the lifted defp,
producing the pre-/post-invariant call sites. Keeping these apart makes both files
easier to read.
2. **Compilation order.** Growing `AnnotatedFunction` past a certain size shifted it
later in the parallel-compile schedule, which raced with the test-support modules
starting their own compilation. Splitting the emission logic out of
`AnnotatedFunction` keeps that file smaller and avoids the race.
The functions here are called from `Bond.Compiler.AnnotatedFunction.apply_contract/2` at
the user module's `__before_compile__` time, and produce AST that's spliced into the
user's module override clause.
"""
alias Bond.Compiler.Assertion
@typedoc """
Descriptor for a single struct-bearing parameter in a function head.
* `{:bound, var_name, param_index}` — the parameter is bound to a variable in the head
(via `%__MODULE__{} = name`, `name = %__MODULE__{}`, or a bare `name` with an
`is_struct(name, __MODULE__)` guard somewhere in the guard list, including inside
compound `and`/`or` expressions). `param_index` is 0-based.
* `{:destructure, param_index}` — the parameter destructures `%__MODULE__{...}` but
does not bind the whole struct to a variable. Invariant emission rewrites the
override clause head at this position to capture the struct under a generated name.
"""
@type struct_param ::
{:bound, atom(), non_neg_integer()}
| {:destructure, non_neg_integer()}
@doc """
Finds every struct-bearing parameter in a function head, in left-to-right order.
Returns a list of `t:struct_param/0` descriptors. Returns `[]` when no parameter and
no guard mentions the module's struct.
Recognised patterns:
* `%__MODULE__{} = name` (or destructure-and-bind, e.g. `%__MODULE__{field: x} = name`)
* `name = %__MODULE__{}` (reversed)
* bare `name` plus `is_struct(name, __MODULE__)` somewhere in the guards, including
inside arbitrary nesting of `and` / `or`
* `%__MODULE__{...}` destructure with no `= name` — returned as `{:destructure, idx}`
Multiple struct parameters in the same head (e.g. `def merge(%__MODULE__{} = a,
%__MODULE__{} = b)`) all appear in the result.
Fully-qualified module patterns (`%MyMod{}`) and aliased forms are not recognised —
invariants are scoped to the struct's own defining module, so `__MODULE__` is the
idiomatic form.
"""
@spec detect_struct_params([Macro.t()], [Macro.t()]) :: [struct_param()]
def detect_struct_params(params, guards) when is_list(params) and is_list(guards) do
guard_vars = collect_guard_struct_vars(guards)
params
|> Enum.with_index()
|> Enum.flat_map(fn {param, idx} ->
case classify_param(param, guard_vars) do
{:bound, name} -> [{:bound, name, idx}]
:destructure -> [{:destructure, idx}]
:none -> []
end
end)
end
@doc """
Resolves the per-function invariant mode given the per-module config value and the
annotated function's `:kind` and `:invariants` list.
Returns `:purge` when:
* the user explicitly purged invariants, OR
* the function is `defp` (private functions are exempt by the Eiffel convention), OR
* the module has no `@invariant`s registered.
Otherwise returns the supplied `true`/`false` mode unchanged.
"""
@spec resolve_mode(mode :: Bond.Compiler.AnnotatedFunction.mode(), :def | :defp, list()) ::
Bond.Compiler.AnnotatedFunction.mode()
def resolve_mode(:purge, _kind, _invariants), do: :purge
def resolve_mode(_value, :defp, _invariants), do: :purge
def resolve_mode(_value, _kind, []), do: :purge
def resolve_mode(value, _kind, _invariants) when value in [true, false], do: value
@doc """
Returns `true` when at least one clause in the given list has SOME invariant-
check mechanism — either:
* its head pattern-matches the struct (pre-invariant fires on entry), or
* its body statically returns the struct or `{:ok, struct}` (post-invariant
fires on exit).
Returns `false` when no clause has either mechanism, meaning invariants are
fully skipped for the function — the footgun `:warn_skipped_invariants` warns
about.
Used by `Bond.Compiler.AnnotatedFunction.apply_contract/2` to decide whether to
emit the warning at the function definition site. The post-invariant body
heuristic is intentionally conservative: it detects the common constructor
shapes (`%__MODULE__{...}`, `{:ok, %__MODULE__{...}}`, and the same as the last
expression in a block) but not function calls or branching expressions whose
return shape can't be determined statically. Users with constructors that
build the struct via a helper call still suppress with
`@bond_warn_skipped_invariants false`.
"""
@spec any_clause_checks_invariants?([term()], module()) :: boolean()
def any_clause_checks_invariants?(clauses, struct_module) when is_list(clauses) do
Enum.any?(clauses, fn clause ->
detect_struct_params(clause.params || [], clause.guards || []) != [] or
body_returns_struct?(clause.body, struct_module)
end)
end
# Detects whether a function clause's body statically returns the struct (so
# the runtime post-invariant check will fire). Returns true for:
#
# - direct: `%__MODULE__{...}`
# - wrapped: `{:ok, %__MODULE__{...}}`
# - block-bodied versions of either (last expression of a `:__block__`)
#
# `struct_module` is passed through for future extension (e.g. detecting
# `%MyMod{...}` aliases), but is currently unused — Bond's idiomatic form
# for invariant-declaring modules is `%__MODULE__{...}` (see
# `detect_struct_params/2`'s @doc).
defp body_returns_struct?([{:do, expr} | _], struct_module) do
expression_returns_struct?(expr, struct_module)
end
defp body_returns_struct?(_body, _struct_module), do: false
defp expression_returns_struct?({:__block__, _, statements}, struct_module) do
case List.last(statements) do
nil -> false
last -> expression_returns_struct?(last, struct_module)
end
end
defp expression_returns_struct?({:%, _, [{:__MODULE__, _, _}, _]}, _struct_module), do: true
defp expression_returns_struct?({:ok, inner}, struct_module) do
expression_returns_struct?(inner, struct_module)
end
defp expression_returns_struct?(_expr, _struct_module), do: false
@doc """
Emits one pre-invariant statement per detected struct parameter, in left-to-right
order. Each statement calls the lifted invariants defp with the appropriate variable:
* `{:bound, var, _}` — the bound variable from the function head.
* `{:destructure, idx}` — `__bond_subject_<idx>__`, the captured-struct variable
for a destructure-only position.
The defp rebinds `subject` to that value and runs every `@invariant` assertion
against it. Returns `[]` when the kind is purged or when no struct parameters were
detected.
"""
@spec all_pre_invariant_stmts(
atom(),
[struct_param()],
Bond.Compiler.AnnotatedFunction.mode(),
Bond.Compiler.AnnotatedFunction.mode(),
Bond.Compiler.AnnotatedFunction.mode()
) :: [Macro.t()]
def all_pre_invariant_stmts(_name, _params, :purge, _pre_mode, _post_mode), do: []
def all_pre_invariant_stmts(_name, [], _mode, _pre_mode, _post_mode), do: []
def all_pre_invariant_stmts(name, struct_params, mode, pre_mode, post_mode) do
chain = %{preconditions: pre_mode, postconditions: post_mode}
for sp <- struct_params do
arg_var = subject_var(sp)
# The lifted invariant defp carries `@dialyzer {:nowarn_function, ...}` (see
# `build_lifted_defp/5`), so the subject's narrowed type can flow in unwrapped:
# any `pattern_match` warning from an `@invariant` duplicating a field's typespec
# (e.g. `subject.capacity >= 0` on a `non_neg_integer()` field) is suppressed at
# the defp rather than laundered at the call boundary.
quote do
if Bond.Runtime.Eval.should_evaluate?(
:invariants,
unquote(mode),
unquote(Macro.escape(chain))
) do
Bond.Runtime.Eval.evaluate_invariants(fn ->
unquote(name)(unquote(arg_var))
end)
end
end
end
end
defp subject_var({:bound, var, _idx}), do: Macro.var(var, nil)
defp subject_var({:destructure, idx}), do: Macro.var(:"__bond_subject_#{idx}__", nil)
@doc """
Emits the post-invariant case-extraction statements that go *after* the postconditions
and before the return.
Delegates the `%<struct_module>{}` / `{:ok, %<struct_module>{}}` shape match to
`Bond.Runtime.Eval.check_struct_invariant/3` rather than emitting a `case var!(result)`
into the user's module. Emitting that `case` here lets Elixir's type checker (1.18+)
prove the struct clauses unreachable for functions that return other shapes (e.g. a
`size/1` returning an integer), producing "the following clause will never match"
warnings that fail `--warnings-as-errors` in downstream builds. Doing the match in
`Eval` (where `result` is typed `term()`) keeps the runtime behaviour identical while
emitting no shape-match into the using module. The struct module is unquoted in rather
than spelled `%__MODULE__{}` to avoid a deferred-`__MODULE__` reference that confuses
parallel compilation.
The runtime `should_evaluate?/3` gate stays at the call site so the bridging closure is
allocated only when invariants are actually evaluated.
"""
@spec post_invariant_stmts(
atom(),
Bond.Compiler.AnnotatedFunction.mode(),
module(),
Bond.Compiler.AnnotatedFunction.mode(),
Bond.Compiler.AnnotatedFunction.mode()
) :: [Macro.t()]
def post_invariant_stmts(_name, :purge, _struct_module, _pre_mode, _post_mode), do: []
def post_invariant_stmts(name, mode, struct_module, pre_mode, post_mode) do
chain = %{preconditions: pre_mode, postconditions: post_mode}
# The post-extracted subject flows into the lifted defp unwrapped; the defp's
# `@dialyzer {:nowarn_function, ...}` suppresses any narrowed-type warning — see
# the corresponding note in `all_pre_invariant_stmts/5`.
[
quote do
if Bond.Runtime.Eval.should_evaluate?(
:invariants,
unquote(mode),
unquote(Macro.escape(chain))
) do
Bond.Runtime.Eval.check_struct_invariant(
var!(result),
unquote(struct_module),
fn __bond_post_value__ ->
unquote(name)(__bond_post_value__)
end
)
end
end
]
end
@doc """
Builds the lifted `defp __bond_invariants__<fun>__<arity>(value)` that the pre- and
post-invariant call sites delegate to. Returns `nil` when invariants are purged.
"""
@spec build_lifted_defp(
atom(),
[Assertion.t(:invariant)],
Assertion.function_info(),
Macro.Env.t(),
Bond.Compiler.AnnotatedFunction.mode()
) :: Macro.t() | nil
def build_lifted_defp(_name, _invariants, _function_info, _env, :purge), do: nil
def build_lifted_defp(name, invariants, function_info, env, _mode) do
body = Assertion.invariants_body(invariants, function_info)
quote file: env.file, line: env.line do
# Suppress Dialyzer warnings for this generated defp instead of widening the
# caller's argument types through `Bond.Predicates.__opaque__/1`. An `@invariant`
# that duplicates a field's typespec-implied fact makes `and`/`or` branches in the
# assertion appear dead; nowarn silences that at zero runtime cost. The subject is
# already `term()`-checked at the call site, so little real coverage is lost.
@dialyzer {:nowarn_function, [{unquote(name), 1}]}
defp unquote(name)(var!(bond_invariant_value)) do
unquote(body)
end
end
end
defp classify_param(param, guard_vars) do
case param do
# A match (`=`) with a top-level variable on one side and a struct match
# (possibly nested) on the other. Binds the variable to the struct.
#
# The nested case matters because Bond's own per-clause wrapper rewrite
# (`Clauses.rewrite_clause_params/3`) wraps a struct-binding head as
# `canonical = (%__MODULE__{} = user_var)` whenever the canonical name at
# that position is generated — which happens for multi-clause functions
# whose clauses disagree on the position's name (e.g. a struct clause
# alongside a binary clause). We bind the *outer* variable: it's the
# canonical name the wrapper body references, while the inner one has been
# underscore-prefixed as intentionally-unused.
{:=, _, [{var, _, ctx}, rhs]} when is_atom(var) and is_atom(ctx) ->
if struct_pattern?(rhs), do: {:bound, var}, else: :none
{:=, _, [lhs, {var, _, ctx}]} when is_atom(var) and is_atom(ctx) ->
if struct_pattern?(lhs), do: {:bound, var}, else: :none
{var, _, ctx} when is_atom(var) and is_atom(ctx) ->
if MapSet.member?(guard_vars, var), do: {:bound, var}, else: :none
{:%, _, [{:__MODULE__, _, _}, _]} ->
:destructure
_ ->
:none
end
end
# Does this pattern match a `%__MODULE__{}` struct, directly or along a chain
# of `=` matches? `%__MODULE__{}`, `%__MODULE__{} = x`, and
# `a = (%__MODULE__{} = b)` all qualify; an unrelated `%OtherMod{}` does not.
defp struct_pattern?({:%, _, [{:__MODULE__, _, _}, _]}), do: true
defp struct_pattern?({:=, _, [lhs, rhs]}), do: struct_pattern?(lhs) or struct_pattern?(rhs)
defp struct_pattern?(_), do: false
defp collect_guard_struct_vars(guards) do
guards
|> Enum.flat_map(&walk_guard_for_is_struct/1)
|> MapSet.new()
end
defp walk_guard_for_is_struct({:is_struct, _, [{var, _, ctx}, {:__MODULE__, _, _}]})
when is_atom(var) and is_atom(ctx) do
[var]
end
defp walk_guard_for_is_struct({op, _, [left, right]}) when op in [:and, :or] do
walk_guard_for_is_struct(left) ++ walk_guard_for_is_struct(right)
end
defp walk_guard_for_is_struct(_), do: []
end