lib/espec/let/let.ex

defmodule ESpec.Let do
  @moduledoc """
  Defines 'let', 'let!' and 'subject' macros.
  'let' and 'let!' macros define named functions with cached return values.
  The 'let' evaluate block in runtime when called first time.
  The 'let!' evaluates as a before block just after all 'befores' for example.
  The 'subject' macro is just an alias for let to define `subject`.
  """

  @doc "Struct keeps the name of variable and random function name."
  defstruct var: nil, module: nil, function: nil, shared: false, shared_module: nil

  @doc """
  The macro defines function with random name which returns block value.
  That function will be called when example is run.
  The function will place the block value to the Agent dict.
  """
  defmacro let(var, do: block), do: do_let(var, block)

  @doc "Allows to define several 'lets' at once"
  defmacro let(keyword) when is_list(keyword) do
    if Keyword.keyword?(keyword) do
      Enum.map(keyword, fn {var, block} -> do_let(var, block) end)
    else
      raise "Argument must be a Keyword"
    end
  end

  @doc "Defines overridable lets in shared examples"
  defmacro let_overridable(keywords) when is_list(keywords) do
    if Keyword.keyword?(keywords) do
      Enum.map(keywords, fn {var, block} -> do_let(var, block, true) end)
    else
      Enum.map(keywords, &do_let(&1, nil, true))
    end
  end

  defmacro let_overridable(var), do: do_let(var, nil, true)

  defp do_let(var, block, shared \\ false) do
    block = Macro.escape(quote(do: unquote(block)), unquote: true)

    quote bind_quoted: [block: block, var: var, shared: shared] do
      function = ESpec.Let.Impl.random_let_name()

      if shared && !@shared do
        raise ESpec.LetError, ESpec.Let.__overridable_error_message__(var, __MODULE__)
      end

      tail = @context

      head = %ESpec.Let{
        var: var,
        module: __MODULE__,
        shared_module: __MODULE__,
        function: function,
        shared: shared
      }

      def unquote(function)(var!(shared)) do
        var!(shared)
        unquote(block)
      end

      @context [head | tail]

      unless Module.defines?(__MODULE__, {var, 0}, :def) do
        def unquote(var)() do
          ESpec.Let.Impl.let_eval(__MODULE__, unquote(var))
        end
      end
    end
  end

  @doc "let! evaluate block like `before`"
  defmacro let!(var, do: block) do
    quote do
      let unquote(var), do: unquote(block)
      before do: unquote(var)()
    end
  end

  @doc "Allows to define several 'lets' at once"
  defmacro let!(keyword) when is_list(keyword) do
    before_block =
      keyword
      |> Keyword.keys()
      |> Enum.map(fn key ->
        quote do: unquote(key)()
      end)

    quote do
      let unquote(keyword)

      before do
        unquote(before_block)
      end
    end
  end

  @doc "Defines 'subject'."
  defmacro subject(do: block) do
    quote do: let(:subject, do: unquote(block))
  end

  defmacro subject(var) do
    quote do: let(:subject, do: unquote(var))
  end

  @doc "Defines 'subject!'."
  defmacro subject!(do: block) do
    quote do: let!(:subject, do: unquote(block))
  end

  defmacro subject!(var) do
    quote do: let!(:subject, do: unquote(var))
  end

  defmacro subject(var, do: block) do
    quote do: let(unquote(var), do: unquote(block))
  end

  defmacro subject!(var, do: block) do
    quote do: let!(unquote(var), do: unquote(block))
  end

  @doc """
  Defines 'let' for success result tuple.
  """
  defmacro let_ok(var, do: block) do
    do_result_let(var, block, :ok, false)
  end

  defmacro let_ok(keyword) when is_list(keyword) do
    if Keyword.keyword?(keyword) do
      Enum.map(keyword, fn {var, block} -> do_result_let(var, block, :ok, false) end)
    else
      raise "Argument must be a Keyword"
    end
  end

  @doc """
  Defines 'let!' for success result tuple.
  """
  defmacro let_ok!(var, do: block) do
    do_result_let(var, block, :ok, true)
  end

  defmacro let_ok!(keyword) when is_list(keyword) do
    if Keyword.keyword?(keyword) do
      Enum.map(keyword, fn {var, block} -> do_result_let(var, block, :ok, true) end)
    else
      raise "Argument must be a Keyword"
    end
  end

  @doc """
  Defines 'let' for error result tuple.
  """
  defmacro let_error(var, do: block) do
    do_result_let(var, block, :error, false)
  end

  defmacro let_error(keyword) when is_list(keyword) do
    if Keyword.keyword?(keyword) do
      Enum.map(keyword, fn {var, block} -> do_result_let(var, block, :error, false) end)
    else
      raise "Argument must be a Keyword"
    end
  end

  @doc """
  Defines 'let!' for error result tuple.
  """
  defmacro let_error!(var, do: block) do
    do_result_let(var, block, :error, true)
  end

  defmacro let_error!(keyword) when is_list(keyword) do
    if Keyword.keyword?(keyword) do
      Enum.map(keyword, fn {var, block} -> do_result_let(var, block, :error, true) end)
    else
      raise "Argument must be a Keyword"
    end
  end

  defp do_result_let(var, block, key, bang?) do
    new_block =
      quote do
        {unquote(key), result} = unquote(block)
        result
      end

    if bang? do
      quote do: let!(unquote(var), do: unquote(new_block))
    else
      quote do: let(unquote(var), do: unquote(new_block))
    end
  end

  @doc false
  def __overridable_error_message__(var, module) do
    "You are trying to define overridable let `#{var}` in #{module}. Defining of overridable lets is allowed only in shared modules"
  end
end