lib/mockable.ex

defmodule Mockable do
  require Logger

  @moduledoc """
  Documentation for `Mockable`.
  """

  @doc """
  Configures which module to use for a given mockable module.
  If no implementation is provided, the mockable module itself will be used.

  This is useful for testing your Mockable module.

  ```elixir
  Mockable.use(Client)
  ```

  Or to use a specific implementation:
  ```elixir
  Mockable.use(Client, ClientMock)
  ```

  This function stores the configuration in process memory so that it is compatible with async tests.
  """
  def use(mockable, implementation \\ nil) do
    implementation = implementation || mockable
    Process.put({Mockable, mockable}, implementation)
  end

  @doc """
  Configures an implementation to be used only for the duration of the given function.
  This is used internally and is not expected to be useful for end users, but is documented for completeness.
  """
  def use(mockable, implementation, func) do
    before = Process.get({Mockable, mockable}, :not_set)
    Process.put({Mockable, mockable}, implementation)

    try do
      func.()
    after
      case before do
        :not_set -> Process.delete({Mockable, mockable})
        _ -> Process.put({Mockable, mockable}, before)
      end
    end
  end

  defmacro __using__(_opts) do
    quote do
      @before_compile unquote(__MODULE__)
      @behaviour __MODULE__
    end
  end

  defmacro __before_compile__(env) do
    module = env.module
    module_string = module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")

    if Application.get_env(:mockable, module) do
      callback_specs =
        Module.get_attribute(module, :callback)
        |> Enum.map(fn {:callback, spec, _} ->
          case spec do
            {:"::", _, [{name, _, args}, _return_type]} ->
              {{name, length(args)}, spec}
          end
        end)
        |> Map.new()
        |> Map.take(Module.definitions_in(module, :def))

      wrapped_functions =
        for {{name, arity}, spec} <- callback_specs do
          args = Macro.generate_arguments(arity, __MODULE__)

          quote do
            defoverridable [{unquote(name), unquote(arity)}]

            @spec unquote(spec)
            def unquote(name)(unquote_splicing(args)) do
              implementation =
                Process.get({Mockable, unquote(module)}) ||
                  Application.get_env(:mockable, unquote(module))

              if implementation != unquote(module) do
                Mockable.log_implementation_usage(
                  implementation,
                  unquote(name),
                  unquote(arity),
                  unquote(module_string)
                )

                apply(implementation, unquote(name), [unquote_splicing(args)])
              else
                Mockable.log_implementation_usage(
                  implementation,
                  unquote(name),
                  unquote(arity),
                  unquote(module_string)
                )

                super(unquote_splicing(args))
              end
            end
          end
        end

      impl_functions =
        for {{name, arity}, _spec} <- callback_specs do
          args = Macro.generate_arguments(arity, __MODULE__)

          quote do
            def unquote(name)(unquote_splicing(args)) do
              Mockable.use(unquote(module), unquote(module), fn ->
                apply(unquote(module), unquote(name), [unquote_splicing(args)])
              end)
            end
          end
        end

      quote do
        require Logger
        (unquote_splicing(wrapped_functions))

        defmodule Impl do
          @behaviour unquote(module)
          (unquote_splicing(impl_functions))
        end
      end
    end
  end

  if Application.compile_env(:mockable, :log, true) do
    def log_implementation_usage(implementation, function_name, arity, module) do
      Logger.debug("Using #{inspect(implementation)}.#{function_name}/#{arity} for #{module}")
    end
  else
    def log_implementation_usage(_implementation, _function_name, _arity, _module), do: :ok
  end
end