Current section
Files
Jump to
Current section
Files
lib/corsa.ex
# TODO: custom error messages in contracts
# TODO: auto-generate contract docs
defmodule Corsa do
@moduledoc """
`Corsa` is a library to write executable code contracts in Elixir.
## Installation
The package can be installed by adding `corsa` to your list of dependencies in `mix.exs`:
def deps do
[
{:corsa, "~> #{Corsa.MixProject.project[:version]}"}
]
end
## Contracts
Corsa contracts are written using normal pre- and post-condition contracts familiar from other
programming languages and libraries such as Eiffel. To use the library in a module we must first
insert a
use Corsa
statement in the module. Suppose next that we have implemented a function `list_to_nat` which
converts an integer represented as a list of small numbers (between 0 and 9) to an integer:
Suppose next that we have implemented a function `list_to_nat` which converts an integer
represented as a list of small numbers (between 0 and 9) to an integer. We can require that the
argument to `list_to_nat` is correct by specifying a `@pre` contract:
iex> defmodule Example do
...> use Corsa
...> @pre list_to_nat(l), do: is_decimal_list(l)
...> @post list_to_nat(l), do: is_integer(result)
...> def list_to_nat(list), do: list_to_nat(list, 0)
...> defp list_to_nat([], result), do: result
...> defp list_to_nat([ n| rest ], acc) do
...> list_to_nat(rest ,10*acc+n)
...> end
...> defp is_decimal_list([n|rest]) do
...> is_decimal(n) and (n > 0)
...> and Enum.all?(rest,&is_decimal/1)
...> end
...> defp is_decimal_list(_), do: false
...> defp is_decimal(n) do
...> is_integer(n) and (n >= 0) and (n < 10)
...> end
...> end
iex> Example.list_to_nat([1,5])
15
iex> Example.list_to_nat(5)
** (Corsa.PreViolationError) @pre does not hold in call 'CorsaDoctestTest.Example.list_to_nat(5)'
If we now invoke the function with an incorrect argument by default an informative exception is
raised.
Similarly `@post` contracts check that the value returned by a function is correct:
## Assertions
Assertions can be stated using the `@assert` construct:
iex> defmodule AssertExample do
...> use Corsa
...> def f(y) do
...> x = g(y)
...> @assert x > 0
...> x + 10
...> end
...> defp g(x) do x end
...> end
iex> AssertExample.f(10)
20
iex> AssertExample.f(-10)
** (Corsa.AssertViolationError) @assert does not hold in expression 'x > 0' with values left '-10' and right '0'
## Termination Guarantees
Corsa can check whether recursive functions eventually terminate, through specifying a measure
using the `@decreases` construct:
iex> defmodule DecreasesExample1 do
...> use Corsa
...> @decreases sum(l), do: length(l)
...> def sum([]), do: 0
...> def sum([n|rest]), do: n+sum(rest)
...> end
iex> DecreasesExample1.sum([1,2,3])
6
The `@decreases` annotation specifies a function, that when applied to the arguments of the called
function, calculates a measure (e.g., an integer). Corsa checks that measure when a function is
invoked is strictly greater than the measure in recursive calls. In the example above, the
measure used is the length of the least which strictly decreases in recursive calls. The check
will be made even if the recursive call is made within another function.
Note, however, that a decreasing measure does not necessarily guarantee termination:
defmodule DecreasesExample2 do
use Corsa
@decreases fact(n), do: n
def fact(1), do: 1
def fact(n), do: n * fact(n - 1)
end
If the function is invoked with `fact(-1)` then it obviously never terminates (except possibly
with an exception) but the measure always strictly decreases. Note you can "fix" the previous
contract by using `abs/1`:
iex> defmodule DecreasesExample3 do
...> use Corsa
...> @decreases fact(n), do: abs(n)
...> def fact(1), do: 1
...> def fact(n), do: n * fact(n - 1)
...> end
iex> DecreasesExample3.fact(10)
3628800
iex> DecreasesExample3.fact(0)
** (Corsa.DecreasesViolationError) @decreases does not hold, call 'CorsaDoctestTest.DecreasesExample3.fact(-1)' does not decrease, previous measure: '0', new measure: '1'
## Time Contracts
Time bounds for long a function application may execute can be specified with the `@within`
construct:
iex> defmodule WithinExample do
...> use Corsa
...> @within f(n) do n end
...> def f(n) do Process.sleep(n + 10) end
...> end
iex> WithinExample.f(1)
** (Corsa.WithinViolationError) @within does not hold in call 'CorsaDoctestTest.WithinExample.f(1)'
If the call to f function does not return an answer within n + 10 milliseconds, an error is
logged.
## Message Passing Contracts
The `@expects` contract specifies the accepatble messages in a receive statement.
iex> defmodule ExpectsExample do
...> use Corsa
...> def counter(state) do
...> @expects v > 0, receive do
...> :inc -> counter(state + 1)
...> :dec -> counter(state - 1)
...> {:set, v} -> counter(v)
...> end
...> end
...> end
iex> send(self(), :inc)
iex> send(self(), {:set, 10})
iex> send(self(), {:set, -1})
iex> ExpectsExample.counter(0)
** (Corsa.ExpectsViolationError) @expects does not hold in expression 'v > 0' with values left '-1' and right '0'
## Variable Contracts
Variable contracts allow you to define variable contracts. In Corsa, these contracts restrict the values
that may be assigned to a variable.
# iex> defmodule DeclareExample do
# ...> use Corsa
# ...> @declare is_integer(n) && n > 0
# ...> def counter(n) do
# ...> receive do
# ...> :inc -> counter(n + 1)
# ...> :dec -> counter(n - 1)
# ...> {:set, n} -> counter(n)
# ...> end
# ...> end
# ...> end
# iex> send(self(), :inc)
# iex> send(self(), {:set, 10})
# iex> send(self(), {:set, -1})
# iex> DeclareExample.counter(0)
## Type Contracts
When using `Corsa` type annotations will be used to generate runtime typechecks for your
functions.
iex> defmodule TypedExample do
...> use Corsa
...> @spec id(integer()) :: integer()
...> @spec id(boolean()) :: boolean()
...> def id(x) do x end
...> end
iex> TypedExample.id(10)
10
iex> TypedExample.id(true)
true
iex> TypedExample.id("hello")
** (Corsa.SpecArgViolationError) @spec does not hold in arguments in call 'CorsaDoctestTest.TypedExample.id("hello")'
## State Contracts
iex> defmodule StateExample do
...> use Corsa, state: true
...> @init :not_started
...>
...> @next start(), do: 0
...> @throws start(), do: not started?(:not_started)
...> def start() do
...> try do
...> :ets.new(:counter, [:named_table])
...> :ets.insert(:counter, {"value", 0})
...> :ok
...> catch
...> _, _ -> raise "error already started"
...> end
...> end
...>
...> @next inc(), do: state + 1
...> @throws inc(), do: started?(state)
...> def inc() do
...> [{"value", v}] = :ets.lookup(:counter, "value")
...> :ets.insert(:counter, {"value", v + 1})
...> :ok
...> end
...>
...> @throws get(), do: started?(state)
...> def get() do
...> [{"value", v}] = :ets.lookup(:counter, "value")
...> v
...> end
...> def started?(state), do: state != :not_started
...> end
iex> StateExample.start()
iex> StateExample.inc()
iex> StateExample.inc()
iex> StateExample.get()
2
iex> StateExample.start()
** (RuntimeError) error already started
### Monitors
## Configuring Corsa
Corsa can be configured to either (the default) raise exceptions whenever a contract is violated,
or log contract violations, or do nothing.
### Logging levels
iex> defmodule LogsExample do
...> use Corsa, logger: :error
...> @pre list_to_nat(l), do: is_decimal_list(l)
...> @post list_to_nat(l), do: is_integer(result)
...> def list_to_nat(list), do: list_to_nat(list, 0)
...> defp list_to_nat([], result), do: result
...> defp list_to_nat([ n| rest ], acc) do
...> list_to_nat(rest ,10*acc+n)
...> end
...> defp is_decimal_list([n|rest]) do
...> is_decimal(n) and (n > 0)
...> and Enum.all?(rest,&is_decimal/1)
...> end
...> defp is_decimal_list(_), do: false
...> defp is_decimal(n) do
...> is_integer(n) and (n >= 0) and (n < 10)
...> end
...> end
### Disabling contracts
iex> defmodule DisabledContracts do
...> use Corsa, disabled: true
...> @pre f(x), do: x > 0
...> @post f(x), do: result > 0
...> def f(x), do: x
...> end
iex> DisabledContracts.f(-1)
-1
"""
@doc false
@spec __using__(logger: Logger.level(), state: boolean(), disabled: boolean()) :: Macro.t()
defmacro __using__(opts) do
context = __CALLER__.module
Module.register_attribute(context, :corsa_options, accumulate: true)
Keyword.get(opts, :disabled, Application.get_env(:corsa, :disabled))
|> then(&Module.put_attribute(context, :corsa_options, {:disabled, &1}))
quote context: context do
require Corsa
import Kernel, except: [@: 1]
require Logger
import Corsa.Utils, only: [to_corsa_call: 3]
# The order here is important, as it tells how the macros are composed
import Corsa
# TODO check what is going on with declare
# use Corsa.Declare
use Corsa.Type
use Corsa.Assert, unquote(opts)
use Corsa.Expects
use Corsa.Throws
use Corsa.Within
use Corsa.Spec
use Corsa.Post
use Corsa.Decreases
use Corsa.Pre
use Corsa.StateM, unquote(opts)
end
end
@doc false
defmacro @ast do
context = __CALLER__.module
state? = Module.get_attribute(context, :state)
disabled? = Module.get_attribute(context, :corsa_options, []) |> Keyword.get(:disabled, false)
case ast do
{tag, _line, _expr}
when disabled? and
tag in [:assert, :declare, :pre, :post, :throws, :decreases, :within, :init, :next] ->
[]
{:assert, _line, expr} ->
quote context: context do
Corsa.Assert.assert(unquote_splicing(expr))
end
{:expects, _line, expr} when disabled? ->
{_, clauses} = Corsa.Utils.parse_expects(Enum.reverse(expr), __CALLER__)
quote context: context do
receive do
unquote(clauses)
end
end
{:expects, _line, expr} ->
quote context: context do
Corsa.Expects.expects(unquote(expr))
end
# {:declare, _line, expr} ->
# quote context: context do
# declare(unquote(expr))
# end
{:pre, _line, [{:when, _context, _args}, [do: _body]]} ->
reraise(
Corsa.PreError,
"guards are not supported in @pre",
Macro.Env.stacktrace(__CALLER__)
)
{:pre, _line, contract = [{_name, _, _args}, [do: _]]} ->
quote context: context do
Corsa.Pre.pre(unquote_splicing(contract))
end
{:pre, _, _} ->
reraise(Corsa.PreError, "syntax error in @pre", Macro.Env.stacktrace(__CALLER__))
{:post, _line, [{:when, _context, _args}, [do: _body]]} ->
reraise(
Corsa.PostError,
"guards are not supported in @post",
Macro.Env.stacktrace(__CALLER__)
)
{:post, _line, contract = [{_name, _, _args}, [do: _]]} ->
quote context: context do
Corsa.Post.post(unquote_splicing(contract))
end
{:post, _, _} ->
reraise(Corsa.PostError, "syntax error in @post", Macro.Env.stacktrace(__CALLER__))
{:throws, _line, [{:when, _context, _args}, [do: _body]]} ->
reraise(
Corsa.ThrowsError,
"guards are not supported in @throws",
Macro.Env.stacktrace(__CALLER__)
)
{:throws, _line, contract = [{_name, _, _args}, [do: _]]} ->
quote context: context do
Corsa.Throws.throws(unquote_splicing(contract))
end
{:throws, _, _} ->
reraise(Corsa.ThrowsError, "syntax error in @throws", Macro.Env.stacktrace(__CALLER__))
{:decreases, _line, [{:when, _context, _args}, [do: _body]]} ->
reraise(
Corsa.DecreasesError,
"guards are not supported in @decreases",
Macro.Env.stacktrace(__CALLER__)
)
{:decreases, _line, contract = [{_name, _, _args}, [do: _]]} ->
quote context: context do
Corsa.Decreases.decreases(unquote_splicing(contract))
end
{:decreases, _, _} ->
reraise(
Corsa.DecreasesError,
"syntax error in @decreases",
Macro.Env.stacktrace(__CALLER__)
)
{:within, _line, [{:when, _context, _args}, [do: _body]]} ->
reraise(
Corsa.WithinError,
"guards are not supported in @within",
Macro.Env.stacktrace(__CALLER__)
)
{:within, _line, contract = [{_name, _, _args}, [do: _]]} ->
quote context: context do
Corsa.Within.within(unquote_splicing(contract))
end
{:within, _, _} ->
reraise(Corsa.WithinError, "syntax error in @within", Macro.Env.stacktrace(__CALLER__))
{:spec, _line, expr} when disabled? ->
quote context: context do
@spec unquote_splicing(expr)
end
{:spec, _line, expr = [{:"::", _, [f = {_name, _, _args}, result_type]}]} ->
quote context: context do
@spec unquote_splicing(expr)
Corsa.Spec.spec(unquote(f), unquote(result_type))
end
{:init, _line, _expr} when not state? ->
reraise(
Corsa.StateM.InitError,
"to use @init you need to add the option state: true",
Macro.Env.stacktrace(__CALLER__)
)
{:next, _line, _expr} when not state? ->
reraise(
Corsa.StateM.InitError,
"to use @next you need to add the option state: true",
Macro.Env.stacktrace(__CALLER__)
)
{:init, _line, expr} ->
quote context: context do
Corsa.StateM.Init.init(unquote_splicing(expr))
end
{:next, _line, contract} ->
quote context: context do
Corsa.StateM.Next.next(unquote_splicing(contract))
end
_ ->
quote do
Kernel.@(unquote(ast))
end
end
end
end