lib/contexted/delegator.ex

defmodule Contexted.Delegator do
  @moduledoc """
  The `Contexted.Delegator` module provides a macro to delegate all functions defined within a specific module.

  This module can be used to forward all function calls from one module to another without the need to write
  individual `defdelegate` statements for each function.

  ## Usage

  To use the `delegate_all` macro, simply include it in your module and pass the target module as an argument:

      defmodule MyContextModule do
        import Contexted.Delegator

        delegate_all MyTargetSubcontextModule
      end

  All public functions defined in `MyTargetSubcontextModule` will now be accessible within `MyContextModule`.

  Note: This macro should be used with caution, as it may lead to unexpected behaviors if two modules with overlapping function names are delegated.

  ## Additional configuration

  The `Contexted.Delegator` module can be used without `Mix.Tasks.Compile.Contexted` as one of the compilers.

  However, if you wish to enable automatic `@doc` and `@spec` generation for delegated functions, you will need to set the following config:

      config :contexted,
        enable_recompilation: true
  """

  alias Contexted.{ModuleAnalyzer, Utils}

  @doc """
  Delegates all public functions of the given module.

  ## Examples

      defmodule MyContextModule do
        import Contexted.Delegator

        delegate_all MyTargetSubcontextModule
      end

  All public functions defined in `MyTargetSubcontextModule` will now be accessible within `MyContextModule`.
  """
  defmacro delegate_all(module) do
    # Ensure the module is an atom
    module =
      case module do
        {:__aliases__, _, _} ->
          Macro.expand(module, __CALLER__)

        _ ->
          module
      end

    functions_docs = ModuleAnalyzer.get_functions_docs(module)
    functions_specs = ModuleAnalyzer.get_functions_specs(module)

    # Get the module's public functions
    functions =
      module.__info__(:functions)
      |> Enum.filter(fn {name, arity} -> :erlang.function_exported(module, name, arity) end)
      |> Enum.map(fn {name, arity} ->
        args = ModuleAnalyzer.generate_random_function_arguments(arity)
        doc = ModuleAnalyzer.get_function_doc(functions_docs, name, arity)
        spec = ModuleAnalyzer.get_function_spec(functions_specs, name, arity)

        {name, arity, args, doc, spec}
      end)

    # Generate the defdelegate AST for each function
    delegates =
      Enum.map(functions, fn {name, _arity, args, doc, spec} ->
        quote do
          if unquote(doc), do: unquote(Code.string_to_quoted!(doc))

          if unquote(spec) && Utils.recompilation_enabled?(),
            do: unquote(Code.string_to_quoted!(spec))

          defdelegate unquote(name)(unquote_splicing(args)),
            to: unquote(module),
            as: unquote(name)
        end
      end)

    types =
      case Code.Typespec.fetch_types(module) do
        {:ok, types} ->
          Enum.map(types, fn
            {type_of_type, type} ->
              Code.Typespec.type_to_quoted(type)
              |> add_ast_for_type(type_of_type)

            _ ->
              nil
          end)

        _ ->
          []
      end

    # Combine the generated delegates into a single AST
    quote do
      (unquote_splicing(types))
      (unquote_splicing(delegates))
    end
  end

  @spec add_ast_for_type(tuple(), atom()) :: tuple()
  defp add_ast_for_type(ast, type_of_type) do
    {:@, [context: Elixir, imports: [{1, Kernel}]],
     [
       {type_of_type, [context: Elixir],
        [
          ast
        ]}
     ]}
  end
end