lib/prove.ex

defmodule Prove do
  @moduledoc """
  Prove provides the macros `prove` and `batch` to write simple tests in `ExUnit`
  shorter.

  A `prove` is just helpful for elementary tests. Prove generates one test with
  one assert for every `prove`.

  The disadvantage of these macros is that the tests are containing fewer
  descriptions. For this reason and also if a `prove` looks too complicated, a
  regular `test` is to prefer.

  ## Example

  ```elixir
  defmodule NumTest do
    use ExUnit.Case

    import Prove

    defmodule Num do
      def check(0), do: :zero

      def check(x) when is_integer(x) do
        case rem(x, 2) do
          0 -> :even
          1 -> :odd
        end
      end

      def check(_), do: :error
    end

    describe "check/1" do
      prove Num.check(0) == :zero

      batch "returns :odd or :even" do
        prove Num.check(1) == :odd
        prove Num.check(2) == :even
        prove "for big num", Num.check(2_000) == :even
      end

      batch "returns :error" do
        prove Num.check("1") == :error
        prove Num.check(nil) == :error
      end
    end
  end
  ```
  The example above generates the following tests:
  ```shell
  $> mix test test/num_test.exs --trace --seed 0

  NumTest [test/num_test.exs]
    * prove check/1 (1) (0.00ms) [L#20]
    * prove check/1 returns :odd or :even (1) (0.00ms) [L#23]
    * prove check/1 returns :odd or :even (2) (0.00ms) [L#24]
    * prove check/1 returns :odd or :even for big num (1) (0.00ms) [L#25]
    * prove check/1 returns :error (1) (0.00ms) [L#29]
    * prove check/1 returns :error (2) (0.00ms) [L#30]


  Finished in 0.08 seconds (0.00s async, 0.08s sync)
  6 proves, 0 failures

  Randomized with seed 0
  ```

  The benefit of `prove` is that tests with multiple asserts can be avoided.
  The example above with regular `test`s:
  ```elixir
  ...
    describe "check/1" do
      test "returns :zero" do
        assert Num.check(0) == :zero
      end

      test "returns :odd or :even" do
        assert Num.check(1) == :odd
        assert Num.check(2) == :even
        assert Num.check(2_000) == :even
      end

      test "returns :error" do
        assert Num.check("1") == :error
        assert Num.check(nil) == :error
      end
    end
  ...
  ```
  ```shell
  $> mix test test/num_test.exs --trace --seed 0

  NumTest [test/num_test.exs]
    * test check/1 returns :zero (0.00ms) [L#36]
    * test check/1 returns :odd or :even (0.00ms) [L#40]
    * test check/1 returns :error (0.00ms) [L#46]


  Finished in 0.03 seconds (0.00s async, 0.03s sync)
  3 tests, 0 failures

  Randomized with seed 0
  ```

  """

  @operator [:==, :<, :>, :<=, :>=, :===, :=~, :!==, :!=, :in]

  @doc """
  A macro to write simple a simple test shorter.

  Code like:
  ```elxir
  prove identity(5) == 5
  prove identity(6) > 5
  prove "check:", identity(7) == 7
  ```
  is equivalent to:
  ```elixir
  test "(1)" do
    assert identity(5) == 5
  end

  test "(2)" do
    assert identity(6) > 5
  end

  test "check: (1)" do
    assert identity(7) == 7
  end
  ```

  `prove` supports the operators `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`,
  and `=~`.
  """
  defmacro prove(description \\ "", expr)

  defmacro prove(description, {operator, _, [_, _]} = expr)
           when is_binary(description) and operator in @operator do
    quote_prove(
      update_description(description, __CALLER__),
      expr,
      __CALLER__
    )
  end

  defmacro prove(_description, expr) do
    raise ArgumentError, message: "Unsupported do: #{Macro.to_string(expr)}"
  end

  @doc """
  Creates a batch of proves.

  A batch adds the `description` to every `prove`. This can be used to
  group`proves`s with a context. Every prove is still an own `test`.

  Code like:
  ```
  batch "valid" do
    prove 1 == 1
    prove "really", 2 == 2
  end
  ```
  is equivalent to:
  ```
  test "valid (1)" do
    assert 1 == 1
  end

  test "valid really (1)" do
    assert 2 == 2
  end
  ```
  """
  defmacro batch(description, do: {:__block__, _meta, block}) do
    {:__block__, [], quote_block(description, block, __CALLER__)}
  end

  defmacro batch(description, do: block) when is_tuple(block) do
    {:__block__, [], quote_block(description, [block], __CALLER__)}
  end

  defp quote_block(description, block, caller) do
    Enum.map(block, fn
      {:prove, meta, [op]} ->
        quote_block_prove(description, op, meta)

      {:prove, meta, [prove_description, op]} ->
        quote_block_prove("#{description} #{prove_description}", op, meta)

      _error ->
        raise CompileError,
          file: caller.file,
          line: caller.line,
          description: "A batch can only contain prove/1/2 functions"
    end)
  end

  defp quote_block_prove(description, op, meta) do
    {marker, _meta, children} =
      quote do
        prove unquote(description), unquote(op)
      end

    {marker, meta, children}
  end

  defp quote_prove(
         description,
         {operator, _meta, [_, _]} = expr,
         %{module: mod, file: file, line: line}
       )
       when is_binary(description) and operator in @operator do
    assertion = quote_assertion(expr)

    quote generated: true,
          file: file,
          line: line,
          bind_quoted: [
            assertion: Macro.escape(assertion),
            description: description,
            file: file,
            line: line,
            mod: mod
          ] do
      name = ExUnit.Case.register_test(mod, file, line, :prove, description, [])

      def unquote(name)(_) do
        unquote(assertion)
      rescue
        error in [ExUnit.AssertionError] ->
          reraise(error, __STACKTRACE__)
      end
    end
  end

  defp quote_assertion({operator, _meta, [left, right]} = expr) do
    expr = Macro.escape(expr)

    quote do
      left = unquote(left)
      right = unquote(right)

      ExUnit.Assertions.assert(Kernel.apply(Kernel, unquote(operator), [left, right]),
        left: left,
        right: right,
        expr: unquote(expr),
        message: "Prove with #{to_string(unquote(operator))} failed"
      )
    end
  end

  defp update_description(description, caller) do
    case Module.get_attribute(caller.module, :prove_counter) do
      nil ->
        Module.register_attribute(caller.module, :count, persist: false)
        Module.put_attribute(caller.module, :prove_counter, Map.put(%{}, description, 1))
        join(description, 1)

      %{^description => value} = map ->
        inc = value + 1
        Module.put_attribute(caller.module, :prove_counter, Map.put(map, description, inc))
        join(description, inc)

      map ->
        Module.put_attribute(caller.module, :prove_counter, Map.put(map, description, 1))
        join(description, 1)
    end
  end

  defp join("", b), do: "(#{b})"
  defp join(a, b), do: "#{a} (#{b})"
end