Current section

Files

Jump to
bond lib bond protocol impl.ex
Raw

lib/bond/protocol/impl.ex

defmodule Bond.Protocol.Impl do
@moduledoc """
Opt-in contract refinement for a `defimpl` block.
`use Bond.Protocol.Impl` inside a `defimpl` lets that implementation *refine* the contracts
it inherits from the protocol, following Eiffel's behavioural-subtyping rules:
* `@pre_weaken` **weakens** the inherited precondition — effective pre =
`inherited OR pre_weaken`. The implementation accepts everything the abstraction promised,
and *more* (contravariance).
* `@post_strengthen` **strengthens** the inherited postcondition — effective post =
`inherited AND post_strengthen`. Callers get at least the abstract guarantee, and *more*
(covariance).
Refinement expressions reference the **canonical argument names** declared in the protocol's
own `def`, not the implementation parameter names (which are often patterns rather than simple
variables).
## Usage
defprotocol Account do
use Bond.Protocol
@pre positive_amount: amount > 0
@post non_negative: result >= 0
def withdraw(data, amount)
end
defimpl Account, for: SavingsAccount do
use Bond.Protocol.Impl
# Accept zero-amount withdrawals too (no-op); canonical name 'amount' from protocol
@pre_weaken zero_ok: amount == 0
# Also guarantee the balance changed (or stayed the same on zero withdrawal)
@post_strengthen unchanged_on_zero: amount == 0 or result < data.balance
def withdraw(acc, 0), do: acc.balance
def withdraw(acc, amount), do: acc.balance - amount
end
A `@pre_weaken` is only valid when the protocol declares a precondition for that function;
`@post_strengthen` may add a postcondition even if the protocol declared none. `old/1` is
not supported in either annotation (protocol contract v1 restriction).
Implementations that do **not** `use Bond.Protocol.Impl` are completely unaffected.
"""
alias Bond.Compiler.InheritedContracts
alias Bond.Compiler.InheritedContracts.Context
alias Bond.Compiler.ProtocolWrapper
# Module-attribute keys stored as inline atom literals because the impl module has a shadowed
# `@/1` macro — using `@name value` syntax inside __using__ would conflict. Same pattern as
# Bond.Protocol.
defp pending_pre_key, do: :__bond_impl_pending_pre__
defp pending_post_key, do: :__bond_impl_pending_post__
defp refinements_key, do: :__bond_impl_refinements__
@doc false
defmacro __using__(_opts) do
quote do
import Kernel, except: [@: 1, def: 1, def: 2]
import Bond.Protocol.Impl, only: [@: 1, def: 1, def: 2]
@before_compile Bond.Protocol.Impl
end
end
# --- @pre_weaken / @post_strengthen capture (everything else passes to Kernel.@) ---
@doc false
defmacro @pre_post_or_other
defmacro @{refinement, meta, [expression]} when refinement in [:pre_weaken, :post_strengthen] do
kind = if refinement == :pre_weaken, do: :precondition, else: :postcondition
InheritedContracts.accumulate_pending(impl_ctx(), kind, expression, __CALLER__, meta)
:ok
end
defmacro @other do
quote do
Kernel.@(unquote(other))
end
end
# --- def shadow: associate pending refinements with {name, arity}, then forward to Kernel.def ---
@doc false
defmacro def({name, _meta, args} = head, body)
when is_atom(name) and (is_list(args) or is_nil(args)) do
arity = length(args || [])
quote do
Bond.Protocol.Impl.__consume_pending__(__MODULE__, unquote(name), unquote(arity))
Kernel.def(unquote(head), unquote(body))
end
end
@doc false
defmacro def(head) do
quote do
Kernel.def(unquote(head))
end
end
@doc false
def __consume_pending__(module, name, arity) do
pre = (Module.get_attribute(module, pending_pre_key()) || []) |> Enum.reverse()
post = (Module.get_attribute(module, pending_post_key()) || []) |> Enum.reverse()
if pre != [] or post != [] do
existing = Module.get_attribute(module, refinements_key()) || []
Module.put_attribute(module, refinements_key(), [{name, arity, pre, post} | existing])
Module.put_attribute(module, pending_pre_key(), [])
Module.put_attribute(module, pending_post_key(), [])
end
:ok
end
@doc false
defmacro __before_compile__(env) do
module = env.module
protocol = Module.get_attribute(module, :protocol)
# Leftover pending means @pre_weaken / @post_strengthen was not followed by a def.
leftover_pre = InheritedContracts.pending(impl_ctx(), module, :precondition)
leftover_post = InheritedContracts.pending(impl_ctx(), module, :postcondition)
if leftover_pre != [] or leftover_post != [] do
raise CompileError,
file: env.file,
line: env.line,
description:
"Bond: @pre_weaken/@post_strengthen in #{inspect(module)} do not precede a `def`. " <>
"Refinement annotations on a protocol implementation must immediately precede " <>
"the function clause they refine."
end
refinements = (Module.get_attribute(module, refinements_key()) || []) |> Enum.reverse()
statements =
Enum.flat_map(refinements, fn {name, arity, pre_weaken, post_strengthen} ->
build_effective_fns(protocol, module, name, arity, pre_weaken, post_strengthen, env)
end)
{:__block__, [], statements}
end
# --- internal ---
defp build_effective_fns(protocol, module, name, arity, pre_weaken, post_strengthen, env) do
{canonical_arg_names, inherited_pre, inherited_post} =
fetch_protocol_contract!(protocol, module, name, arity, env)
if pre_weaken != [] and inherited_pre == [] do
raise CompileError,
file: env.file,
line: (List.first(pre_weaken) || %{definition_env: env}).definition_env.line || env.line,
description:
"Bond: @pre_weaken on `#{name}/#{arity}` in #{inspect(module)}: the protocol " <>
"#{inspect(protocol)} declares no precondition for `#{name}/#{arity}`, so there " <>
"is nothing to weaken. To add a postcondition where none exists, use " <>
"@post_strengthen instead."
end
InheritedContracts.validate_referenced_names!(
impl_ctx(),
pre_weaken,
post_strengthen,
{name, arity},
canonical_arg_names,
env
)
ProtocolWrapper.build_effective_fns(
name,
arity,
canonical_arg_names,
inherited_pre,
inherited_post,
pre_weaken,
post_strengthen,
module,
env
)
end
defp fetch_protocol_contract!(protocol, impl_module, name, arity, env) do
result =
try do
apply(protocol, :__bond_protocol_contract__, [name, arity])
rescue
UndefinedFunctionError ->
raise CompileError,
file: env.file,
line: env.line,
description:
"Bond: #{inspect(protocol)} does not use Bond.Protocol — " <>
"@pre_weaken/@post_strengthen in #{inspect(impl_module)} require a " <>
"Bond.Protocol protocol."
end
case result do
:no_contract ->
raise CompileError,
file: env.file,
line: env.line,
description:
"Bond: @pre_weaken/@post_strengthen on `#{name}/#{arity}` in " <>
"#{inspect(impl_module)}: #{inspect(protocol)} declares no Bond contract for " <>
"`#{name}/#{arity}`. Add @pre/@post to the protocol definition first."
{arg_names, pre, post} ->
{arg_names, pre, post}
end
end
defp impl_ctx do
%Context{
noun: "function",
contract_subject: "protocol implementation",
reference_scope: "the protocol function's named arguments",
pending_pre_key: pending_pre_key(),
pending_post_key: pending_post_key(),
stamp_source_behaviour: false,
reject_old: true,
arg_naming_hint?: false
}
end
end