Current section
Files
Jump to
Current section
Files
lib/corsa.ex
# TODO: custom error messages in contracts
# TODO: write docs
# TODO: auto-generate contract docs
defmodule Corsa do
@moduledoc """
Corsa is a library to write runnable code contracts in Elixir.
"""
@doc false
@spec __using__(logger: Logger.level()) :: Macro.t()
defmacro __using__(opts) do
context = __CALLER__.module
Module.register_attribute(context, :pre, accumulate: true)
Module.register_attribute(context, :post, accumulate: true)
Module.register_attribute(context, :throws, accumulate: true)
Module.register_attribute(context, :within, accumulate: true)
Module.register_attribute(context, :decreases, accumulate: true)
Module.register_attribute(context, :specs, accumulate: true)
Module.register_attribute(context, :options, accumulate: true)
Keyword.get(opts, :logger, Application.get_env(:corsa, :logger))
|> then(&Module.put_attribute(context, :options, {:logger, &1}))
quote context: context do
require Corsa
import Corsa, except: [assert: 1, assert: 2]
@before_compile unquote(__MODULE__)
import Kernel, except: [@: 1]
use TypeCheck
import TypeCheck.Macros, except: [@: 1]
import TypeCheck, only: [conforms?: 2]
require Logger
end
end
defmacro @{:assert, _line, expr} do
quote context: __CALLER__.module do
Corsa.assert(unquote_splicing(expr))
end
end
defmacro @{:pre, _line, [{:when, _context, _args}, [do: _body]]} do
reraise(Corsa.PreError, "guards are not supported in @pre", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:pre, _line, [{name, _context, args}, [do: body]]} do
arity = length(args)
context = __CALLER__.module
stacktrace = Macro.Env.stacktrace(__CALLER__)
if {name, arity} in Module.get_attribute(context, :pre) do
"@pre for function '#{name}/#{arity}' already defined"
|> then(&reraise(Corsa.PreError, &1, stacktrace))
end
for {arg, _, _} <- args, arg = Atom.to_string(arg), match?("_" <> _, arg) do
"arguments in @pre cannot be ignored with _ "
|> then(&reraise(Corsa.PreError, &1, stacktrace))
end
unless args == Enum.uniq(args) do
"arguments in @pre should contain different names"
|> then(&reraise(Corsa.PreError, &1, stacktrace))
end
Module.put_attribute(context, :pre, {name, arity})
opts = Macro.unique_var(:opts, context)
quote context: context do
defp unquote(:"#{name}_pre")(unquote_splicing(args)) do
unquote(opts) =
[unquote_splicing(args)]
|> to_corsa_call(__MODULE__, unquote(name))
|> then(&[call: &1])
Corsa.assert(unquote(body), Corsa.PreViolationError, Corsa.PreError, unquote(opts))
end
end
end
defmacro @{:pre, _line, _expr} do
reraise(Corsa.PreError, "syntax error in @pre", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:post, _line, [{:when, _context, _args}, [do: _body]]} do
reraise(Corsa.PostError, "guards are not supported in @post", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:post, _line, [{name, _context, args}, [do: body]]} do
arity = length(args)
context = __CALLER__.module
result = Macro.var(:result, nil)
stacktrace = Macro.Env.stacktrace(__CALLER__)
if {name, arity} in Module.get_attribute(context, :post) do
"@post for function '#{name}/#{arity}' already defined"
|> then(&reraise(Corsa.PostError, &1, stacktrace))
end
for {arg, _, _} <- args, arg = Atom.to_string(arg), match?("_" <> _, arg) do
"arguments in @post cannot be ignored with _ "
|> then(&reraise(Corsa.PostError, &1, stacktrace))
end
for {arg, _, _} <- args, arg == :result do
"result cannot be an argument in @post"
|> then(&reraise(Corsa.PostError, &1, stacktrace))
end
unless args == Enum.uniq(args) do
"arguments in @post should contain different names"
|> then(&reraise(Corsa.PostError, &1, stacktrace))
end
Module.put_attribute(context, :post, {name, arity})
opts = Macro.unique_var(:opts, context)
quote context: context do
defp unquote(:"#{name}_post")(unquote_splicing(args ++ [result])) do
unquote(opts) =
[unquote_splicing(args)]
|> to_corsa_call(__MODULE__, unquote(name))
|> then(&[call: &1, result: unquote(result)])
Corsa.assert(unquote(body), Corsa.PostViolationError, Corsa.PostError, unquote(opts))
end
end
end
defmacro @{:post, _line, _expr} do
reraise(Corsa.PostError, "syntax error in @post", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:throws, _line, [{:when, _context, _args}, [do: _body]]} do
reraise(
Corsa.ThrowsError,
"guards are not supported in @throws",
Macro.Env.stacktrace(__CALLER__)
)
end
defmacro @{:throws, _line, [{name, _context, args}, [do: body]]} do
arity = length(args)
context = __CALLER__.module
stacktrace = Macro.Env.stacktrace(__CALLER__)
if {name, arity} in Module.get_attribute(context, :throws) do
"@throws for function '#{name}/#{arity}' already defined"
|> then(&reraise(Corsa.ThrowsError, &1, stacktrace))
end
for {arg, _, _} <- args, arg = Atom.to_string(arg), match?("_" <> _, arg) do
"arguments in @throws cannot be ignored with _ "
|> then(&reraise(Corsa.ThrowsError, &1, stacktrace))
end
for {arg, _, _} <- args, arg == :kind or arg == :value do
"'#{arg}' cannot be an argument in @throws"
|> then(&reraise(Corsa.ThrowsError, &1, stacktrace))
end
unless args == Enum.uniq(args) do
"arguments in @throws should contain different names"
|> then(&reraise(Corsa.ThrowsError, &1, stacktrace))
end
Module.put_attribute(context, :throws, {name, arity})
opts = Macro.unique_var(:opts, context)
quote context: context do
defp unquote(:"#{name}_throws")(unquote_splicing(args), kind, value) do
unquote(opts) =
[unquote_splicing(args)]
|> to_corsa_call(__MODULE__, unquote(name))
|> then(&[call: &1, kind: kind, reason: value])
Corsa.assert(unquote(body), Corsa.ThrowsViolationError, Corsa.ThrowsError, unquote(opts))
end
end
end
defmacro @{:throws, _line, _expr} do
reraise(Corsa.ThrowsError, "syntax error in @throws", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:within, _line, [{:when, _context, _args}, [do: _body]]} do
reraise(
Corsa.WithinError,
"guards are not supported in @within",
Macro.Env.stacktrace(__CALLER__)
)
end
defmacro @{:within, _line, [{name, _context, args}, [do: body]]} do
arity = length(args)
context = __CALLER__.module
stacktrace = Macro.Env.stacktrace(__CALLER__)
if {name, arity} in Module.get_attribute(context, :within) do
"@within for function '#{name}/#{arity}' already defined"
|> then(&reraise(Corsa.WithinError, &1, stacktrace))
end
for {arg, _, _} <- args, arg = Atom.to_string(arg), match?("_" <> _, arg) do
"arguments in @within cannot be ignored with _ "
|> then(&reraise(Corsa.WithinError, &1, stacktrace))
end
unless args == Enum.uniq(args) do
"arguments in @within should contain different names"
|> then(&reraise(Corsa.WithinError, &1, stacktrace))
end
Module.put_attribute(context, :within, {name, arity})
quote context: context do
defp unquote(:"#{name}_within")(unquote_splicing(args)) do
[unquote_splicing(args)]
|> to_corsa_call(__MODULE__, unquote(name))
|> then(
&try do
unquote(body)
catch
k, v ->
reraise(Corsa.WithinError, %{call: &1, kind: k, reason: v}, __STACKTRACE__)
end
)
end
end
end
defmacro @{:within, _line, _expr} do
reraise(Corsa.WithinError, "syntax error in @within", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:decreases, _line, [{:when, _context, _args}, [do: _body]]} do
reraise(
Corsa.DecreasesError,
"guards are not supported in @decreases",
Macro.Env.stacktrace(__CALLER__)
)
end
defmacro @{:decreases, _line, [{name, _context, args}, [do: body]]} do
arity = length(args)
context = __CALLER__.module
stacktrace = Macro.Env.stacktrace(__CALLER__)
if {name, arity} in Module.get_attribute(context, :decreases) do
"@decreases for function '#{name}/#{arity}' already defined"
|> then(&reraise(Corsa.DecreasesError, &1, stacktrace))
end
for {arg, _, _} <- args, arg = Atom.to_string(arg), match?("_" <> _, arg) do
"arguments in @decreases cannot be ignored with _ "
|> then(&reraise(Corsa.DecreasesError, &1, stacktrace))
end
unless args == Enum.uniq(args) do
"arguments in @decreases should contain different names"
|> then(&reraise(Corsa.DecreasesError, &1, stacktrace))
end
Module.put_attribute(context, :decreases, {name, arity})
old_args = Enum.map(args, fn {name, env, ctx} -> {:"#{name}_old", env, ctx} end)
id = Macro.unique_var(:id, context)
opts = Macro.unique_var(:opts, context)
quote context: context do
defp unquote(:"#{name}_decreases_measure")(unquote_splicing(args)) do
unquote(body)
end
defp unquote(:"#{name}_decreases")(unquote_splicing(args)) do
unquote(id) = {__MODULE__, unquote(name), unquote(arity)}
unless Corsa.Stack.empty?(unquote(id)) do
[unquote_splicing(old_args)] = Corsa.Stack.peek(unquote(id))
unquote(opts) = [
old_call:
[unquote_splicing(old_args)]
|> Corsa.to_corsa_call(__MODULE__, unquote(name)),
call:
[unquote_splicing(args)]
|> Corsa.to_corsa_call(__MODULE__, unquote(name))
]
Corsa.assert(
unquote(:"#{name}_decreases_measure")(unquote_splicing(args)) <
unquote(:"#{name}_decreases_measure")(unquote_splicing(old_args)),
Corsa.DecreasesViolationError,
Corsa.DecreasesError,
unquote(opts)
)
end
Corsa.Stack.push(unquote(id), [unquote_splicing(args)])
end
end
end
defmacro @{:decreases, _line, _expr} do
reraise(Corsa.DecreasesError, "syntax error in @decreases", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:expects, _line, expr} when is_list(expr) do
context = __CALLER__.module
{predicates, clauses} = parse_expects(expr, __CALLER__)
global_vars = __CALLER__.versioned_vars |> Map.keys() |> Enum.map(&elem(&1, 0)) |> MapSet.new()
clauses =
for {:->, env, [pattern, block]} <- clauses do
{_, vars} = free_vars_names(pattern)
overlapped = MapSet.intersection(vars, global_vars)
if overlapped != MapSet.new() do
env = Macro.Env.stacktrace(__CALLER__)
if Enum.count(overlapped) == 1 do
var = overlapped |> Enum.at(0) |> Atom.to_string()
IO.warn("Variable \"#{var}\" is overriden inside pattern in @expects contract", env)
else
vars = overlapped |> Enum.map_join(", ", fn v -> "\"#{Atom.to_string(v)}\"" end)
IO.warn("Variables #{vars} are overriden inside pattern in @expects contract", env)
end
end
checks =
Enum.filter(predicates, fn {_, pred_vars} ->
MapSet.subset?(pred_vars, MapSet.union(global_vars, vars))
end)
|> Enum.map(fn {pred, _} ->
quote context: context do
Corsa.assert(unquote(pred), Corsa.ExpectsViolationError, Corsa.ExpectsError)
end
end)
block =
quote context: context do
unquote_splicing(checks)
unquote(block)
end
{:->, env, [pattern, block]}
end
quote context: context do
receive do
unquote(clauses)
end
end
end
defmacro @{:expects, _line, _expr} do
reraise(Corsa.ExpectsError, "syntax error in @expects", Macro.Env.stacktrace(__CALLER__))
end
defmacro @{:spec, _line, expr = [{:"::", _, [{name, _, args}, result_type]}]} do
context = __CALLER__.module
arity = length(args)
stacktrace = Macro.Env.stacktrace(__CALLER__)
args =
Enum.reduce(args, {[], 1}, fn
{:"::", _, [arg, type]}, {args, n} -> {[{arg, type} | args], n + 1}
type, {args, n} -> {[{Macro.var(:"arg#{n}", context), type} | args], n + 1}
end)
|> elem(0)
|> Enum.reverse()
{args_names, _args_types} = Enum.unzip(args)
unless args == Enum.uniq(args) do
"arguments in @spec should contain different names"
|> then(&reraise(Corsa.SpecError, &1, stacktrace))
end
args_check =
Enum.map(args, fn {arg, type} ->
quote do: TypeCheck.conforms?(unquote(arg), unquote(type))
end)
|> Enum.reduce(true, fn expr, acc -> quote do: unquote(acc) and unquote(expr) end)
result = Macro.unique_var(:result, context)
if {name, arity} in Module.get_attribute(context, :specs) do
overridable = [{:"#{name}_args_spec", arity}, {:"#{name}_result_spec", arity + 1}]
quote context: context do
@spec unquote_splicing(expr)
defoverridable(unquote(overridable))
@dialyzer {:no_return, unquote(:"#{name}_args_spec/#{arity}")}
defp unquote(:"#{name}_args_spec")(unquote_splicing(args_names)) do
super(unquote_splicing(args_names)) or unquote(args_check)
end
@dialyzer {:no_return, unquote(:"#{name}_result_spec/#{arity + 1}")}
defp unquote(:"#{name}_result_spec")(unquote_splicing(args_names ++ [result])) do
super(unquote_splicing(args_names ++ [result])) or
(unquote(args_check) and TypeCheck.conforms?(unquote(result), unquote(result_type)))
end
end
else
Module.put_attribute(context, :specs, {name, arity})
quote context: context do
@spec unquote_splicing(expr)
@dialyzer {:nowarn_function, unquote({:"#{name}_args_spec", arity})}
defp unquote(:"#{name}_args_spec")(unquote_splicing(args_names)) do
unquote(args_check)
end
@dialyzer {:nowarn_function, unquote({:"#{name}_result_spec", arity + 1})}
defp unquote(:"#{name}_result_spec")(unquote_splicing(args_names ++ [result])) do
unquote(args_check) and TypeCheck.conforms?(unquote(result), unquote(result_type))
end
end
end
end
defmacro @ast do
case ast do
{name, _, expr} when name in ~w[type! typep! opaque! spec!]a ->
quote do
TypeCheck.Macros.unquote(name)(unquote_splicing(expr))
end
_ ->
quote do
Kernel.@(unquote(ast))
end
end
end
@spec __before_compile__(Macro.Env.t()) :: Macro.t()
defmacro __before_compile__(env) do
module = env.module
defs =
Module.definitions_in(module, :def) |> Enum.map(fn {name, arity} -> {:def, name, arity} end)
defps =
Module.definitions_in(module, :defp) |> Enum.map(fn {name, arity} -> {:defp, name, arity} end)
for f = {_def_t, name, arity} <- defs ++ defps,
function_with_contracts?(f, module),
{_, _, location, _} = Module.get_definition(module, {name, arity}) do
file = __CALLER__.file
Module.make_overridable(module, [{name, arity}])
new_def(f, module, location ++ [file: file])
end
end
defp new_def({def_t, name, arity}, context, line: line, file: _file) do
args = Macro.generate_arguments(arity, nil)
defs = Module.definitions_in(context)
args_spec =
if {:"#{name}_args_spec", arity} in defs do
quote context: context, line: line do
Corsa.assert(
unquote(:"#{name}_args_spec")(unquote_splicing(args)),
Corsa.SpecArgViolationError,
Corsa.SpecError,
call: call
)
end
end
precondition =
if {:"#{name}_pre", arity} in defs do
quote context: context, line: line do
unquote(:"#{name}_pre")(unquote_splicing(args))
end
end
within =
if {:"#{name}_within", arity} in defs do
quote context: context, line: line do
within = unquote(:"#{name}_within")(unquote_splicing(args))
timer_ref =
if within do
Gtimer.new_timer(
within,
fn pid ->
%{call: call}
|> Corsa.WithinViolationError.exception()
|> then(&Process.exit(pid, &1))
end
)
end
end
end
post_within =
if {:"#{name}_within", arity} in defs do
quote context: context, line: line do
Gtimer.cancel_timer(timer_ref)
end
end
throws =
cond do
{:"#{name}_throws", arity + 2} in defs ->
quote context: context, line: line do
unquote(:"#{name}_throws")(unquote_splicing(args), kind, value)
end
{:"#{name}_post", arity + 1} in defs ->
quote context: context, line: line do
reraise(
Corsa.PostViolationError,
"@post does not hold in call \"#{call}\" which raised an exception of kind \"#{kind}\" with the value \"#{inspect(value)}\"",
__STACKTRACE__
)
end
true ->
nil
end
postcondition =
if {:"#{name}_post", arity + 1} in defs do
quote context: context, line: line do
unquote(:"#{name}_post")(unquote_splicing(args), result)
end
end
result_spec =
if {:"#{name}_result_spec", arity + 1} in defs do
quote context: context, line: line do
Corsa.assert(
unquote(:"#{name}_result_spec")(unquote_splicing(args), result),
Corsa.SpecResultViolationError,
Corsa.SpecError,
call: call,
result: result
)
end
end
decreases =
if {:"#{name}_decreases", arity} in defs do
quote context: context, line: line do
unquote(:"#{name}_decreases")(unquote_splicing(args))
end
end
quote context: context, line: line do
unquote(def_t)(unquote(name)(unquote_splicing(args))) do
call = to_corsa_call([unquote_splicing(args)], __MODULE__, unquote(name))
unquote(within)
try do
unquote(args_spec)
unquote(precondition)
unquote(decreases)
super(unquote_splicing(args))
catch
kind, value = %{corsa_exception: true} ->
Corsa.Stack.flush({__MODULE__, unquote(name), unquote(arity)})
:erlang.raise(kind, value, __STACKTRACE__)
kind, value ->
unquote(throws)
unquote(post_within)
Corsa.Stack.flush({__MODULE__, unquote(name), unquote(arity)})
:erlang.raise(kind, value, __STACKTRACE__)
else
result ->
unquote(post_within)
unquote(result_spec)
unquote(postcondition)
Corsa.Stack.pop({__MODULE__, unquote(name), unquote(arity)})
result
end
end
end
end
##############################################################################
# Helper macros
##############################################################################
defmacro assert(
expr,
violation \\ Corsa.AssertViolationError,
error \\ Corsa.AssertError,
opts \\ []
) do
{_, vars} = free_vars(expr)
context = __CALLER__.module
vars = for var = {name, _, _} <- vars, do: {name, var}
logger_level = Module.get_attribute(__CALLER__.module, :options) |> Keyword.get(:logger)
expr_var = Macro.unique_var(:expr, context)
vars_var = Macro.unique_var(:vars, context)
opts_var = Macro.unique_var(:opts, context)
violation_var = Macro.unique_var(:violation, context)
error_var = Macro.unique_var(:error, context)
result_var = Macro.unique_var(:result, context)
check =
if logger_level do
quote context: context do
{:current_stacktrace, [_entry1, entry2 | stacktrace]} =
Process.info(self(), :current_stacktrace)
{module, function, arity, [file: source, line: line]} = entry2
Map.new(unquote(opts_var))
|> unquote(violation).exception()
|> then(
&Logger.log(unquote(logger_level), "#{&1.message} [#{source}:#{line}]",
crash_reason: {&1, entry2}
)
)
end
else
quote context: context do
raise(unquote(violation_var), Map.new(unquote(opts_var)))
end
end
quote context: context do
unquote(expr_var) = unquote(Macro.escape(expr))
unquote(vars_var) = unquote(vars)
unquote(opts_var) = unquote(opts) ++ [expr: unquote(expr_var), vars: unquote(vars_var)]
unquote(violation_var) = unquote(violation)
unquote(error_var) = unquote(error)
try do
unquote(expr)
else
unquote(result_var) ->
if unquote(result_var) do
true
else
unquote(check)
end
catch
k, v ->
unquote(opts_var) = unquote(opts_var) ++ [kind: k, reason: v]
reraise unquote(error), Map.new(unquote(opts_var)), __STACKTRACE__
end
end
end
##############################################################################
# Helper functions
##############################################################################
defp free_vars(expr) do
{_, free_vars} =
Macro.prewalk(expr, [], fn
e = {_, _, nil}, acc -> {e, [e | acc]}
e = {_, _, ctx}, acc when is_atom(ctx) -> {e, [e | acc]}
e, acc -> {e, acc}
end)
{expr, MapSet.new(free_vars)}
end
defp free_vars_names(expr) do
{expr, vars} = free_vars(expr)
{expr, Enum.map(vars, &elem(&1, 0)) |> MapSet.new()}
end
defp parse_expects(expr, caller) do
expr = Enum.reverse(expr) |> check_expects_syntax(caller)
predicates =
expr
|> List.delete_at(0)
|> List.delete_at(0)
|> Enum.map(&free_vars_names/1)
|> Enum.reverse()
clauses = expr |> hd() |> hd() |> elem(1)
{predicates, clauses}
end
defp check_expects_syntax([[do: {:__block__, _, []}] | _rest], caller) do
reraise(Corsa.ExpectsError, "syntax error in @expects", Macro.Env.stacktrace(caller))
end
defp check_expects_syntax(expr = [[do: _block] | rest], caller) do
if rest == [] || not match?({:receive, _, _}, hd(rest)) do
reraise(Corsa.ExpectsError, "syntax error in @expects", Macro.Env.stacktrace(caller))
end
expr
end
defp check_expects_syntax(_expr, caller) do
reraise(Corsa.ExpectsError, "syntax error in @expects", Macro.Env.stacktrace(caller))
end
defp function_with_contracts?({_, name, arity}, module) do
defs = Module.definitions_in(module)
{:"#{name}_args_spec", arity} in defs or
{:"#{name}_pre", arity} in defs or
{:"#{name}_within", arity} in defs or
{:"#{name}_throws", arity + 2} in defs or
{:"#{name}_post", arity + 1} in defs or
{:"#{name}_result_spec", arity + 1} in defs or
{:"#{name}_decreases", arity} in defs
end
def to_corsa_call(args, module, name) do
Enum.map_join(args, ", ", &inspect(&1)) |> then(&"#{inspect(module)}.#{name}(#{&1})")
end
# def debug_stacktrace(label) do
# IO.puts(inspect(label))
# {_, stacktrace} = Process.info(self(), :current_stacktrace)
# Exception.format_stacktrace(tl(tl(stacktrace))) |> IO.puts()
# :ok
# end
# defp macro_inspect(m) do
# Macro.to_string(m) |> IO.puts()
# m
# end
end