Repository: keathley/norm
Pull request #27 (2019-12-02 21:06:29)

Contracts

keathley: I was thinking about this a bit. Is it a terrible idea to go with something like ‘use Norm’ which would import all of the Norm module functions and also import Norm.Contracts? Would that help eliminate confusion or just create more confusion.

Patch:
diff --git a/lib/norm.ex b/lib/norm.ex
index 61fd307..ed79c60 100644
--- a/lib/norm.ex
+++ b/lib/norm.ex
@@ -349,6 +349,14 @@ defmodule Norm do
   alias Norm.MismatchError
   alias Norm.GeneratorError
 
+  @doc false
+  defmacro __using__(_) do
+    quote do
+      import Norm
+      use Norm.Contract
+    end
+  end
+
   @doc ~S"""
   Verifies that the payload conforms to the specification. A "success tuple"
   is returned that contains either the conformed value or the error explanation.
diff --git a/lib/norm/contract.ex b/lib/norm/contract.ex
new file mode 100644
index 0000000..5e3e30d
--- /dev/null
+++ b/lib/norm/contract.ex
@@ -0,0 +1,168 @@
+defmodule Norm.Contract do
+  @moduledoc """
+  Design by Contract with Norm.
+
+  This module provides a `@contract` macro that can be used to define specs for arguments and the
+  return value of a given function.
+
+  To use contracts, call `use Norm` which also imports all `Norm` functions.
+
+  Sometimes you may want to turn off contracts checking. For example, to skip contracts in production,
+  set: `config :norm, enable_contracts: Mix.env != :prod`.
+
+  ## Examples
+
+      defmodule Colors do
+        use Norm
+
+        def rgb(), do: spec(is_integer() and &(&1 in 0..255))
+
+        def hex(), do: spec(is_binary() and &String.starts_with?(&1, "#"))
+
+        @contract rgb_to_hex(r :: rgb(), g :: rgb(), b :: rgb()) :: hex()
+        def rgb_to_hex(r, g, b) do
+          # ...
+        end
+      end
+
+  """
+
+  @doc false
+  def __before_compile__(env) do
+    contracts = Module.get_attribute(env.module, :norm_contracts)
+    definitions = Module.definitions_in(env.module)
+
+    for {fun, arity} <- contracts do
+      unless {:"__#{fun}_without_contract__", arity} in definitions do
+        raise ArgumentError, "contract for undefined function #{fun}/#{arity}"
+      end
+    end
+  end
+
+  @doc false
+  defmacro __using__(_) do
+    quote do
+      import Kernel, except: [@: 1, def: 2]
+      import Norm.Contract
+      Module.register_attribute(__MODULE__, :norm_contracts, accumulate: true)
+      @before_compile Norm.Contract
+    end
+  end
+
+  @doc false
+  defmacro def(call, expr) do
+    quote do
+      if unquote(fa(call)) in @norm_contracts do
+        unless Module.defines?(__MODULE__, unquote(fa(call))) do
+          Kernel.def(unquote(wrapper_call(call)), do: unquote(wrapper_body(call)))
+        end
+
+        Kernel.def(unquote(call_without_contract(call)), unquote(expr))
+      else
+        Kernel.def(unquote(call), unquote(expr))
+      end
+    end
+  end
+
+  @doc false
+  defmacro @{:contract, _, expr} do
+    defcontract(expr)
+  end
+
+  defmacro @other do
+    quote do
+      Kernel.@(unquote(other))
+    end
+  end
+
+  ## Internals
+
+  defp defcontract(expr) do
+    if Application.get_env(:norm, :enable_contracts, true) do
+      do_defcontract(expr)
+    end
+  end
+
+  defp do_defcontract(expr) do
+    {call, result_spec} =
+      case expr do
+        [{:"::", _, [call, result_spec]}] ->
+          {call, result_spec}
+
+        _ ->
+          actual = Macro.to_string({:@, [], [{:contract, [], expr}]})
+
+          raise ArgumentError,
+                "contract must be in the form " <>
+                  "`@contract function(arg :: spec) :: result_spec`, got: `#{actual}`"
+      end
+
+    {name, call_meta, arg_specs} = call
+
+    arg_vars =
+      for arg_spec <- arg_specs do
+        case arg_spec do
+          {:"::", _, [{arg_name, _, _}, _spec]} ->
+            Macro.var(arg_name, nil)
+
+          _ ->
+            raise ArgumentError,
+                  "argument spec must be in the form `arg :: spec`, " <>
+                    "got: `#{Macro.to_string(arg_spec)}`"
+        end
+      end
+
+    conform_args =
+      for {:"::", _, [{arg_name, _, _}, spec]} <- arg_specs do
+        arg = Macro.var(arg_name, nil)
+
+        quote do
+          Norm.conform!(unquote(arg), unquote(spec))
+        end
+      end
+
+    conform_args = {:__block__, [], conform_args}
+    result = Macro.var(:result, nil)
+    call = {name, call_meta, arg_vars}
+
+    quote do
+      @norm_contracts unquote(fa(call))
+
+      def unquote(call_with_contract(call)) do
+        unquote(conform_args)
+        unquote(result) = unquote(call_without_contract(call))
+        Norm.conform!(unquote(result), unquote(result_spec))
+        unquote(result)
+      end
+    end
+  end
+
+  ## Utilities
+
+  defp wrapper_call(call) do
+    {name, meta, args} = call
+    args = for {_, index} <- Enum.with_index(args), do: Macro.var(:"arg#{index}", nil)
+    {name, meta, args}
+  end
+
+  defp wrapper_body(call) do
+    {name, meta, args} = call
+    args = for {_, index} <- Enum.with_index(args), do: Macro.var(:"arg#{index}", nil)
+    {:"__#{name}_with_contract__", meta, args}
+  end
+
+  defp call_with_contract(call) do
+    {name, meta, args} = call
+    {:"__#{name}_with_contract__", meta, args}
+  end
+
+  defp call_without_contract(call) do
+    {name, meta, args} = call
+    {:"__#{name}_without_contract__", meta, args}
+  end
+
+  defp fa(call) do
+    {name, _meta, args} = call
+    {name, length(args)}
+  end
+end
diff --git a/mix.exs b/mix.exs
index dd0d25f..295d43a 100644
--- a/mix.exs
+++ b/mix.exs
@@ -19,7 +19,9 @@ defmodule Norm.MixProject do
   end
 
   def application do
-    []
+    [
+      env: [enable_contracts: true]
+    ]
   end
 
   defp deps do


Test patch:
diff --git a/test/norm/contract_test.exs b/test/norm/contract_test.exs
new file mode 100644
index 0000000..93c5299
--- /dev/null
+++ b/test/norm/contract_test.exs
@@ -0,0 +1,72 @@
+defmodule Norm.ContractTest do
+  use ExUnit.Case, async: true
+
+  test "success" do
+    defmodule Success do
+      use Norm
+
+      @contract foo(n :: spec(is_integer())) :: spec(is_integer())
+      def foo(n), do: n
+    end
+
+    assert Success.foo(42) == 42
+  end
+
+  test "arg mismatch" do
+    defmodule ArgMismatch do
+      use Norm
+
+      @contract foo(n :: spec(is_integer())) :: spec(is_integer())
+      def foo(n), do: n
+    end
+
+    assert_raise Norm.MismatchError, ~r/val: "42" fails: is_integer\(\)/, fn ->
+      ArgMismatch.foo("42")
+    end
+  end
+
+  test "result mismatch" do
+    defmodule ResultMismatch do
+      use Norm
+
+      @contract foo(n :: spec(is_integer())) :: spec(is_binary())
+      def foo(n), do: n
+    end
+
+    assert_raise Norm.MismatchError, ~r/val: 42 fails: is_binary\(\)/, fn ->
+      ResultMismatch.foo(42)
+    end
+  end
+
+  test "bad contract" do
+    assert_raise ArgumentError, ~r/got: `@contract\(foo\(n\)\)`/, fn ->
+      defmodule BadContract do
+        use Norm
+
+        @contract foo(n)
+        def foo(n), do: n
+      end
+    end
+  end
+
+  test "bad arg" do
+    assert_raise ArgumentError, ~r/`arg :: spec`, got: `spec\(is_integer\(\)\)`/, fn ->
+      defmodule BadArg do
+        use Norm
+
+        @contract foo(spec(is_integer())) :: spec(is_integer())
+        def foo(n), do: n
+      end
+    end
+  end
+
+  test "no function" do
+    assert_raise ArgumentError, "contract for undefined function foo/0", fn ->
+      defmodule NoFunction do
+        use Norm
+
+        @contract foo() :: spec(is_integer())
+      end
+    end
+  end
+end