lib/double.ex

defmodule Double do
  @moduledoc """
  Double builds on-the-fly injectable dependencies for your tests.
  It does NOT override behavior of existing modules or functions.
  Double uses Elixir's built-in language features such as pattern matching and message passing to
  give you everything you would normally need a complex mocking tool for.
  """

  alias Double.{FuncList, Registry, SpyHelper}
  use GenServer

  @default_options [verify: true, send_stubbed_module: false]

  @type allow_option ::
          {:with, [...]}
          | {:returns, any}
          | {:raises,
             String.t()
             | {atom, String.t()}}
  @type double_option :: {:verify, true | false}

  def spy(target) do
    target.module_info(:functions)
    |> Enum.reject(fn {k, _} ->
      [:__info__, :module_info] |> Enum.member?(k) ||
        String.starts_with?("#{k}", "_") ||
        String.starts_with?("#{k}", "-")
    end)
    |> Enum.reduce(stub(target), fn {func, arity}, dbl ->
      args = SpyHelper.create_args(target, arity)

      {f, _} =
        quote do
          fn unquote_splicing(args) ->
            apply(unquote(target), unquote(func), [unquote_splicing(args)])
          end
        end
        |> Code.eval_quoted()

      stub(dbl, func, f)
    end)
  end

  @spec stub(atom, atom, function) :: atom
  def stub(dbl), do: double(dbl, Keyword.put(@default_options, :send_stubbed_module, true))

  def stub(dbl, function_name, func) do
    double_id = Atom.to_string(dbl)
    pid = Registry.whereis_double(double_id)

    dbl =
      case pid do
        :undefined -> stub(dbl)
        _ -> dbl
      end

    dbl
    |> verify_mod_double(function_name, func)
    |> verify_struct_double(function_name)
    |> do_allow(function_name, func)
  end

  @spec double :: map
  @spec double(struct, [double_option]) :: struct
  @spec double(atom, [double_option]) :: atom
  @doc """
  Returns a map that can be used to setup stubbed functions.
  """
  def double, do: double(%{})

  @doc """
  Same as double/0 but can return structs and modules too
  """
  def double(source, opts \\ @default_options) do
    test_pid = self()
    {:ok, pid} = GenServer.start_link(__MODULE__, [])

    double_id =
      case is_atom(source) do
        true ->
          source_name = source |> Atom.to_string() |> String.split(".") |> List.last()
          "#{source_name}Double#{:erlang.unique_integer([:positive])}"

        false ->
          :sha
          |> :crypto.hash(inspect(pid))
          |> Base.encode16()
          |> String.downcase()
      end

    Registry.register_double(double_id, pid, test_pid, source, opts)

    case is_atom(source) do
      true -> double_id |> String.to_atom()
      false -> Map.put(source, :_double_id, double_id)
    end
  end

  @doc """
  Adds a stubbed function to the given map, struct, or module.
  Structs will fail if they are missing the key given for function_name.
  Modules will fail if the function is not defined.
  """
  @spec allow(any, atom, function | [allow_option]) :: struct | map | atom
  def allow(dbl, function_name) when is_atom(function_name),
    do: allow(dbl, function_name, with: [])

  def allow(dbl, function_name, func_opts) when is_list(func_opts) do
    return_values =
      Enum.reduce(func_opts, [], fn {k, v}, acc ->
        if k == :returns, do: acc ++ [v], else: acc
      end)

    return_values = if return_values == [], do: [nil], else: return_values

    option_sets =
      return_values
      |> Enum.reduce([], fn return_value, acc ->
        append_opts =
          func_opts
          |> Keyword.take([:with, :raises])
          |> Keyword.put(:returns, return_value)

        acc ++ [append_opts]
      end)

    option_sets
    |> Enum.reduce(dbl, fn opts, acc ->
      {func, _} = create_function_from_opts(opts)
      allow(acc, function_name, func)
    end)
  end

  def allow(dbl, function_name, func) when is_function(func) do
    dbl
    |> verify_mod_double(function_name, func)
    |> verify_struct_double(function_name)
    |> do_allow(function_name, func)
  end

  @doc """
  Clears stubbed functions from a double. By passing no arguments (or nil) all functions will be
  cleared. A single function name (atom) or a list of function names can also be given.
  """
  @spec clear(any, atom | list) :: struct | map | atom
  def clear(dbl, function_name \\ nil) do
    double_id = if is_atom(dbl), do: Atom.to_string(dbl), else: dbl._double_id
    pid = Registry.whereis_double(double_id)
    GenServer.call(pid, {:clear, dbl, function_name})
  end

  @doc false
  def func_list(pid) do
    GenServer.call(pid, :func_list)
  end

  defp do_allow(dbl, function_name, func) do
    double_id = if is_atom(dbl), do: Atom.to_string(dbl), else: dbl._double_id
    pid = Registry.whereis_double(double_id)
    GenServer.call(pid, {:allow, dbl, function_name, func})
  end

  defp verify_mod_double(dbl, function_name, func) when is_atom(dbl) do
    double_opts = Registry.opts_for("#{dbl}")

    if double_opts[:verify] do
      source = Registry.source_for("#{dbl}")
      source_functions = source.module_info(:functions)

      source_functions =
        if source_functions[:__info__] do
          source_functions ++ source.__info__(:macros)
        else
          source_functions
        end

      source_functions =
        if source_functions[:behaviour_info] do
          source_functions ++ source.behaviour_info(:callbacks)
        else
          source_functions
        end

      stub_arity = :erlang.fun_info(func)[:arity]

      matching_function =
        Enum.find(source_functions, fn {k, v} ->
          k == function_name && v == stub_arity
        end)

      if matching_function == nil do
        raise VerifyingDoubleError,
          message:
            "The function '#{function_name}/#{stub_arity}' is not defined in #{inspect(dbl)}"
      end
    end

    dbl
  end

  defp verify_mod_double(dbl, _, _), do: dbl

  defp verify_struct_double(%{__struct__: _} = dbl, function_name) do
    if Enum.member?(Map.keys(dbl), function_name) do
      dbl
    else
      struct_key_error(dbl, function_name)
    end
  end

  defp verify_struct_double(dbl, _), do: dbl

  # SERVER

  def init([]) do
    {:ok, pid} = GenServer.start_link(FuncList, [])
    {:ok, %{func_list: pid}}
  end

  @doc false
  def handle_call(:func_list, _from, state) do
    {:reply, state.func_list, state}
  end

  @doc false
  def handle_call({:allow, dbl, function_name, func}, _from, state) do
    FuncList.push(state.func_list, function_name, func)

    dbl =
      case is_atom(dbl) do
        true ->
          stub_module(dbl, state)
          dbl

        false ->
          dbl
          |> Map.put(
            function_name,
            stub_function(dbl._double_id, function_name, func)
          )
      end

    {:reply, dbl, state}
  end

  @doc false
  def handle_call({:clear, dbl, function_name}, _from, state) do
    FuncList.clear(state.func_list, function_name)
    {:reply, dbl, state}
  end

  defp stub_module(mod, state) do
    funcs =
      state.func_list
      |> FuncList.list()
      |> Enum.uniq_by(fn {function_name, func} ->
        {function_name, get_arity(func)}
      end)

    opts = Registry.opts_for("#{mod}")
    stubbed_module = Registry.source_for("#{mod}")

    code = """
    defmodule :#{mod} do
    """

    code =
      Enum.reduce(funcs, code, fn {function_name, func}, acc ->
        {signature, message} =
          function_parts(function_name, func, {opts[:send_stubbed_module], stubbed_module})

        acc <>
          """
            #{unimport_if_needed(function_name, func)}
            def #{function_name}(#{signature}) do
              #{function_body(mod, message, function_name, signature)}
            end
          """
      end)

    code = code <> "\nend"
    Double.Eval.eval(code)
  end

  defp stub_function(double_id, function_name, func) do
    {signature, message} = function_parts(function_name, func, {false, nil})

    func_str = """
    fn(#{signature}) ->
      #{function_body(double_id, message, function_name, signature)}
    end
    """

    {result, _} = Code.eval_string(func_str)
    result
  end

  defp function_body(double_id, message, function_name, signature) do
    """
    test_pid = Double.Registry.whereis_test(\"#{double_id}\")
    Kernel.send(test_pid, #{message})
    pid = Double.Registry.whereis_double(\"#{double_id}\")
    func_list = Double.func_list(pid)
    Double.FuncList.apply(func_list, :#{function_name}, [#{signature}])
    """
  end

  defp function_parts(function_name, func, {send_stubbed_module, stubbed_module}) do
    signature =
      case get_arity(func) do
        0 ->
          ""

        x ->
          0..(x - 1)
          |> Enum.map(fn i -> <<97 + i::utf8>> end)
          |> Enum.join(", ")
      end

    message =
      case {send_stubbed_module, signature} do
        {true, _} -> "{#{atom_to_code_string(stubbed_module)}, :#{function_name}, [#{signature}]}"
        {false, ""} -> ":#{function_name}"
        _ -> "{:#{function_name}, #{signature}}"
      end

    {signature, message}
  end

  defp unimport_if_needed(function_name, func) do
    if Enum.member?(Kernel.__info__(:functions), {function_name, func_arity = get_arity(func)}) do
      "import Kernel, except: [#{function_name}: #{func_arity}]"
    end
  end

  defp get_arity(func) do
    :erlang.fun_info(func)[:arity]
  end

  defp struct_key_error(dbl, key) do
    msg =
      "The struct #{dbl.__struct__} does not contain key: #{key}. Use a Map if you want to add dynamic function names."

    raise ArgumentError, message: msg
  end

  defp create_function_from_opts(opts) do
    args =
      case opts[:with] do
        {:any, with_arity} ->
          0..(with_arity - 1)
          |> Enum.map(fn i -> <<97 + i::utf8>> |> String.to_atom() end)
          |> Enum.map(fn arg_atom -> {arg_atom, [], Elixir} end)

        nil ->
          []

        with_args ->
          with_args
      end

    args
    |> quoted_fn(opts)
    |> Code.eval_quoted()
  end

  defp quoted_fn(args, opts) do
    {:fn, [], [{:->, [], [args, quoted_fn_body(opts, opts[:raises])]}]}
  end

  defp quoted_fn_body(_opts, {error_module, message}) do
    {
      :raise,
      [context: Elixir, import: Kernel],
      [{:__aliases__, [alias: false], [error_module]}, message]
    }
  end

  defp quoted_fn_body(_opts, message) when is_binary(message) do
    {
      :raise,
      [context: Elixir, import: Kernel],
      [message]
    }
  end

  defp quoted_fn_body(opts, nil) do
    opts[:returns]
  end

  defp atom_to_code_string(atom) do
    atom_str = Atom.to_string(atom)

    case String.downcase(atom_str) do
      ^atom_str -> ":#{atom_str}"
      _ -> atom_str
    end
  end
end