lib/patch/mock/code.ex

defmodule Patch.Mock.Code do
  @moduledoc """
  Patch mocks out modules by generating mock modules and recompiling them for a `target` module.

  Patch's approach to mocking a module provides some powerful affordances.

  - Private functions can be mocked.
  - Internal function calls are effected by mocks regardless of the function's visibility without
    having to change the way code is written.
  - Private functions can be optionally exposed in the facade to make it possible to test a
    private function directly without changing its visibility in code.

  # Mocking Strategy

  There are 4 logical modules and 1 GenServer that are involved when mocking a module.

  The 4 logical modules:

  - `target` - The module to be mocked.
  - `facade` - The `target` module is replaced by a `facade` module that intercepts all external
    calls and redirects them to the `delegate` module.
  - `original` - The `target` module is preserved as the `original` module, with the important
    transformation that all local calls are redirected to the `delegate` module.
  - `delegate` - This module is responsible for checking with the `server` to see if a call is
    mocked and should be intercepted.  If so, the mock value is returned, otherwise the `original`
    function is called.

  Each `target` module has an associated GenServer, a `Patch.Mock.Server` that has keeps state
  about the history of function calls and holds the mock data to be returned on interception.  See
  `Patch.Mock.Server` for more information.

  ## Example Target Module

  To better understand how Patch works, consider the following example module.

  ```elixir
  defmodule Example do
    def public_function(argument_1, argument_2) do
      {:public, private_function(argument_1, argument_2)}
    end

    defp private_function(argument_1, argument_2) do
      {:private, argument_1, argument_2}
    end
  end
  ```

  ### `facade` module

  The `facade` module is automatically generated based off the exports of the `target` module.
  It takes on the name of the `provided` module.

  For each exported function, a function is generated in the `facade` module that calls the
  `delegate` module.

  ```elixir
  defmodule Example do
    def public_function(argument_1, argument_2) do
      Patch.Mock.Delegate.For.Example.public_function(argument_1, argument_2)
    end
  end
  ```

  ### `delegate` module

  The `delegate` module is automatically generated based off all the functions of the `target`
  module.  It takes on a name based off the `target` module, see `Patch.Mock.Naming.delegate/1`.

  For each function, a function is generated in the `delegate` module that calls
  `Patch.Mock.Server.delegate/3` delegating to the server named for the `target` module, see
  `Patch.Mock.Naming.server/1`.

  ```elixir
  defmodule Patch.Mock.Delegate.For.Example do
    def public_function(argument_1, argument_2) do
      Patch.Mock.Server.delegate(
        Patch.Mock.Server.For.Example,
        :public_function,
        [argument_1, argument_2]
      )
    end

    def private_function(argument_1, argument_2) do
      Patch.Mock.Server.delegate(
        Patch.Mock.Server.For.Example,
        :private_function,
        [argument_1, argument_2]
      )
    end
  end
  ```

  ### `original` module

  The `original` module takes on a name based off the `provided` module, see
  `Patch.Mock.Naming.original/1`.

  The code is transformed in the following ways.
    - All local calls are converted into remote calls to the `delegate` module.
    - All functions are exported

  ```elixir
  defmodule Patch.Mock.Original.For.Example do
    def public_function(argument_1, argument_2) do
      {:public, Patch.Mock.Delegate.For.Example.private_function(argument_1, argument_2)}
    end

    def private_function(argument_1, argument_2) do
      {:private, argument_1, argument_2}
    end
  end
  ```

  ## External Function Calls

  First, let's examine how calls from outside the module are treated.

  ### Public Function Calls

  Code calling `Example.public_function/2` has the following call flow.

  ```text
  [Caller] -> facade -> delegate -> server -> mocked? -> yes   (Intercepted)
           [Mock Value] <----------------------------|----'
                                                      -> no -> original   (Run Original Code)
           [Original Value] <--------------------------------------'
  ```

  Calling a public function will either return the mocked value if it exists, or fall back to
  calling the original function.

  ### Private Function Calls

  Code calling `Example.private_function/2` has the following call flow.

  ```text
  [Caller] --------------------------> facade
           [UndefinedFunctionError] <-----'
  ```

  Calling a private function continues to be an error from the external caller's point of view.

  The `expose` option does allow the facade to expose private functions, in those cases the call
  flow just follows the public call flow.

  ## Internal Calls

  Next, let's examine how calls from outside the module are treated.

  ### Public Function Calls

  Code in the `original` module calling public functions have their code transformed to call the
  `delegate` module.

  ```text
  original -> delegate -> server -> mocked? -> yes   (Intercepted)
           [Mock Value] <------------------|----'
                                            -> no -> original   (Run Original Code)
           [Original Value] <----------------------------'
  ```

  Since the call is redirected to the `delegate`, calling a public function will either return the
  mocked value if it exists, or fall back to calling the original function.

  ### Private Function Call Flow

  Code in the `original` module calling private functions have their code transformed to call the
  `delegate` module

  ```text
  original -> delegate -> server -> mocked? -> yes   (Intercepted)
           [Mock Value] <------------------|----'
                                            -> no -> original   (Run Original Code)
           [Original Value] <----------------------------'
  ```

  Since the call is redirected to the `delegate`, calling a private function will either return the
  mocked value if it exists, or fall back to calling the original function.

  ## Code Generation

  For additional details on how Code Generation works, see the `Patch.Mock.Code.Generate` module.
  """

  alias Patch.Mock
  alias Patch.Mock.Code.Generate
  alias Patch.Mock.Code.Query
  alias Patch.Mock.Code.Unit

  @type binary_error ::
          :badfile
          | :nofile
          | :not_purged
          | :on_load_failure
          | :sticky_directory

  @type chunk_error ::
          :chunk_too_big
          | :file_error
          | :invalid_beam_file
          | :key_missing_or_invalid
          | :missing_backend
          | :missing_chunk
          | :not_a_beam_file
          | :unknown_chunk

  @type load_error ::
          :embedded
          | :badfile
          | :nofile
          | :on_load_failure

  @type compiler_option :: term()

  @type form :: term()
  @type export_classification :: :builtin | :generated | :normal
  @type exports :: Keyword.t(arity())

  @typedoc """
  Sum-type of all valid options
  """
  @type option :: Mock.exposes_option()

  @doc """
  Extracts the abstract_forms from a module
  """
  @spec abstract_forms(module :: module) ::
          {:ok, [form()]}
          | {:error, :abstract_forms_unavailable}
          | {:error, chunk_error()}
          | {:error, load_error()}
  def abstract_forms(module) do
    with :ok <- ensure_loaded(module),
         {:ok, binary} <- binary(module) do
      case :beam_lib.chunks(binary, [:abstract_code]) do
        {:ok, {_, [abstract_code: {:raw_abstract_v1, abstract_forms}]}} ->
          {:ok, abstract_forms}

        {:error, :beam_lib, details} ->
          reason = elem(details, 0)
          {:error, reason}

        _ ->
          {:error, :abstract_forms_unavailable}
      end
    end
  end

  @doc """
  Extracts the attribtues from a module
  """
  @spec attributes(module :: module()) ::
          {:ok, Keyword.t()}
          | {:error, :attributes_unavailable}
  def attributes(module) do
    with :ok <- ensure_loaded(module) do
      try do
        Keyword.get(module.module_info(), :attributes, [])
      catch
        _, _ ->
          {:error, :attributes_unavailable}
      end
    end
  end

  @doc """
  Classifies an exported mfa into one of the following classifications

  - :builtin - Export is a BIF.
  - :generated - Export is a generated function.
  - :normal - Export is a user defined function.
  """
  @spec classify_export(module :: module(), function :: atom(), arity :: arity()) :: export_classification()
  def classify_export(_, :module_info, 0), do: :generated
  def classify_export(_, :module_info, 1), do: :generated
  def classify_export(module, function, arity) do
    if :erlang.is_builtin(module, function, arity) do
      :builtin
    else
      :normal
    end
  end


  @doc """
  Compiles the provided abstract_form with the given compiler_options

  In addition to compiling, the module will be loaded.
  """
  @spec compile(abstract_forms :: [form()], compiler_options :: [compiler_option()]) ::
          :ok
          | {:error, binary_error()}
          | {:error, {:abstract_forms_invalid, [form()], term()}}
  def compile(abstract_forms, compiler_options \\ []) do
    case :compile.forms(abstract_forms, [:return_errors | compiler_options]) do
      {:ok, module, binary} ->
        load_binary(module, binary)

      {:ok, module, binary, _} ->
        load_binary(module, binary)

      errors ->
        {:error, {:abstract_forms_invalid, abstract_forms, errors}}
    end
  end

  @doc """
  Extracts the compiler options from a module.
  """
  @spec compiler_options(module :: module()) ::
          {:ok, [compiler_option()]}
          | {:error, :compiler_options_unavailable}
          | {:error, chunk_error()}
          | {:error, load_error()}
  def compiler_options(module) do
    with :ok <- ensure_loaded(module),
         {:ok, binary} <- binary(module) do
      case :beam_lib.chunks(binary, [:compile_info]) do
        {:ok, {_, [compile_info: info]}} ->
          filtered_options =
            case Keyword.fetch(info, :options) do
              {:ok, options} ->
                filter_compiler_options(options)

              :error ->
                []
            end

          {:ok, filtered_options}

        {:error, :beam_lib, details} ->
          reason = elem(details, 0)
          {:error, reason}

        _ ->
          {:error, :compiler_options_unavailable}
      end
    end
  end

  @doc """
  Extracts the exports from the provided abstract_forms for the module.

  The exports returned can be controlled by the exposes argument.
  """
  @spec exports(abstract_forms :: [form()], module :: module(), exposes :: Mock.exposes()) :: exports()
  def exports(abstract_forms, module, :public) do
    exports = Query.exports(abstract_forms)
    filter_exports(module, exports, :normal)
  end

  def exports(abstract_forms, module, :all) do
    exports = Query.functions(abstract_forms)
    filter_exports(module, exports, :normal)
  end

  def exports(abstract_forms, module, exposes) do
    exports = exposes ++ Query.exports(abstract_forms)
    filter_exports(module, exports, :normal)
  end

  @doc """
  Given a module and a list of exports filters the list of exports to those that
  have the given classification.

  See `classify_export/3` for information about export classification
  """
  @spec filter_exports(module :: module, exports :: exports(), classification :: export_classification()) :: exports()
  def filter_exports(module, exports, classification) do
    Enum.filter(exports, fn {name, arity} ->
      classify_export(module, name, arity) == classification
    end)
  end

  @doc """
  Freezes a module by generating a copy of it under a frozen name with all remote calls to the
  `target` module re-routed to the frozen module.
  """
  @spec freeze(module :: module) :: :ok | {:error, term}
  def freeze(module) do
    with {:ok, compiler_options} <- compiler_options(module),
         {:ok, _} <- unstick_module(module),
         {:ok, abstract_forms} <- abstract_forms(module),
         frozen_forms = Generate.frozen(abstract_forms, module),
         :ok <- compile(frozen_forms, compiler_options) do
      :ok
    end
  end

  @doc """
  Mocks a module by generating a set of modules based on the `target` module.

  The `target` module's Unit is returned on success.
  """
  @spec module(module :: module(), options :: [option()]) :: {:ok, Unit.t()} | {:error, term}
  def module(module, options \\ []) do
    exposes = options[:exposes] || :public

    with {:ok, compiler_options} <- compiler_options(module),
         {:ok, sticky?} <- unstick_module(module),
         {:ok, abstract_forms} <- abstract_forms(module),
         local_exports = exports(abstract_forms, module, :all),
         remote_exports = exports(abstract_forms, module, exposes),
         delegate_forms = Generate.delegate(abstract_forms, module, local_exports),
         facade_forms = Generate.facade(abstract_forms, module, remote_exports),
         original_forms = Generate.original(abstract_forms, module, local_exports),
         :ok <- compile(delegate_forms),
         :ok <- compile(original_forms, compiler_options),
         :ok <- compile(facade_forms) do
      unit = %Unit{
        abstract_forms: abstract_forms,
        compiler_options: compiler_options,
        module: module,
        sticky?: sticky?
      }

      {:ok, unit}
    end
  end

  @doc """
  Purges a module from the code server
  """
  @spec purge(module :: module()) :: boolean()
  def purge(module) do
    :code.purge(module)
    :code.delete(module)
  end

  @doc """
  Marks a module a sticky
  """
  @spec stick_module(module :: module()) :: :ok | {:error, load_error()}
  def stick_module(module) do
    :code.stick_mod(module)
    ensure_loaded(module)
  end

  @doc """
  Unsticks a module

  Returns `{:ok, was_sticky?}` on success, `{:error, reason}` otherwise
  """
  @spec unstick_module(module :: module()) ::
          {:ok, boolean()}
          | {:error, load_error()}
  def unstick_module(module) do
    with :ok <- ensure_loaded(module) do
      if :code.is_sticky(module) do
        {:ok, :code.unstick_mod(module)}
      else
        {:ok, false}
      end
    end
  end

  ## Private

  @spec binary(module :: module()) :: {:ok, binary()} | {:error, :binary_unavailable}
  defp binary(module) do
    case :code.get_object_code(module) do
      {^module, binary, _} ->
        {:ok, binary}

      :error ->
        {:error, :binary_unavailable}
    end
  end

  @spec ensure_loaded(module :: module()) :: :ok | {:error, load_error()}
  defp ensure_loaded(module) do
    with {:module, ^module} <- Code.ensure_loaded(module) do
      :ok
    end
  end

  @spec filter_compiler_options(options :: [term()]) :: [term()]
  defp filter_compiler_options(options) do
    Enum.filter(options, fn
      {:parse_transform, _} ->
        false

      :makedeps_side_effects ->
        false

      :from_core ->
        false

      _ ->
        true
    end)
  end

  @spec load_binary(module :: module(), binary :: binary()) ::
          :ok
          | {:error, binary_error()}
  defp load_binary(module, binary) do
    with {:module, ^module} <- :code.load_binary(module, '', binary) do
      :ok
    end
  end
end