lib/routex/processing.ex

defmodule Routex.Processing do
  @moduledoc """
  This module provides everything needed to process Phoenix routes. It executes
  the `transform` callbacks from extensions to transform `Phoenix.Router.Route`
  structs and `create_helpers` callbacks to create one unified Helper module.

  **Powerful but thin**
  Although Routex is able to influence the routes in Phoenix applications in profound
  ways, the framework and its extensions are a surprisingly lightweight piece
  of compile-time middleware. This is made possible by the way router modules
  are pre-processed by `Phoenix.Router` itself.

  Prior to compilation of a router module, Phoenix Router registers all routes
  defined in the router module using the attribute `@phoenix_routes`. Each
  route is at that stage a `Phoenix.Router.Route` struct.

  Any route enclosed in a `preprocess_using` block has received a `:private`
  field in which Routex has put which Routex backend to use for that
  particular route. By enumerating the routes, we can process each route using
  the properties of this configuration and set struct values accordingly. This
  processing is nothing more than (re)mapping the Route structs' values.

  After the processing by Routex is finished, the `@phoenix_routes` attribute
  in the router is erased and re-populated with the list of mapped
  Phoenix.Router.Route structs.

  Once the router module enters the compilation stage, Routex is already out of
  the picture and route code generation is performed by Phoenix Router.
  """

  alias Routex.Attrs
  alias Routex.Types, as: T
  alias Routex.Utils
  @type extension_module :: module()
  @type helper_module :: module()

  @doc """
  Callback executed before compilation of a `Phoenix Router`. This callback is added
  to the `@before_compile` callbacks by `Routex.Router`.
  """
  @spec __before_compile__(T.env()) :: :ok
  def __before_compile__(env) do
    Utils.print(__MODULE__, ["Start processing routes"])
    execute_callbacks(env)
  end

  @doc false
  @spec helper_mod_name(module()) :: module
  def helper_mod_name(module), do: Module.concat([module, :RoutexHelpers])

  @doc """
  The main function of this module. Receives as only argument the environment of a
  Phoenix router module.
  """
  @spec execute_callbacks(T.env()) :: :ok
  def execute_callbacks(env) do
    # routes are returned in reversed order of definition
    routes =
      env.module
      |> Utils.get_attribute(:phoenix_routes)
      |> Enum.reverse()

    execute_callbacks(env, routes)
  end

  @spec execute_callbacks(T.env(), T.routes()) :: :ok
  def execute_callbacks(env, routes) when is_list(routes) do
    helper_mod_name = helper_mod_name(env.module)

    grouped_routes =
      routes
      |> put_initial_attrs(helper_mod_name)
      |> group_by_backend()

    {non_processed_routes, backend_routes} = Map.pop(grouped_routes, nil, [])
    backend_routes_callbacks = add_callbacks_map(backend_routes)

    {ast_per_extension, transformed_routes_per_backend} =
      execute(backend_routes_callbacks, env)

    ast_per_extension
    |> generate_helper_ast(helper_mod_name)
    |> create_helper_module(helper_mod_name, env)

    processed_routes = flatten_transformed_routes(transformed_routes_per_backend)

    (non_processed_routes ++ processed_routes)
    |> restore_routes_order()
    |> print_summary()
    |> remove_build_info()
    |> write_routes(env)

    Utils.print(__MODULE__, ["End: ", inspect(__MODULE__), " completed route processing."])

    :ok
  end

  defp debug?, do: System.get_env("ROUTEX_DEBUG") == "true"

  defp print_summary(routes) do
    original_amount =
      Enum.count(routes, fn route -> Routex.Attrs.get(route, :__origin__) == route.path end)

    current_amount = Enum.count(routes)
    generated_amount = current_amount - original_amount

    Routex.Utils.print(__MODULE__, [
      "Routes >> Original: ",
      to_string(original_amount),
      " | Generated: ",
      to_string(generated_amount),
      " | Total: ",
      to_string(current_amount)
    ])

    routes
  end

  defp put_initial_attrs(routes, helper_mod_name) do
    routes
    |> Enum.with_index()
    |> Enum.map(fn {route, index} ->
      rtx = %{
        __origin__: route.path,
        __branch__: [index],
        __helper_mod__: helper_mod_name
      }

      overrides = Map.get(route.private, :rtx, %{})
      attrs = Map.merge(rtx, overrides)

      Attrs.merge(route, attrs)
    end)
  end

  defp group_by_backend(routes) do
    routes |> Enum.group_by(&Attrs.get(&1, :__backend__))
  end

  def add_callbacks_map(routes_per_backend) do
    Enum.map(routes_per_backend, fn {backend, routes} ->
      {backend, backend.callbacks(), routes}
    end)
  end

  defp execute(backend_routes_callbacks, env) do
    transformed_routes_per_backend = transform_routes_per_backend(backend_routes_callbacks, env)

    helpers_ast =
      generate_helpers_ast(:create_helpers, transformed_routes_per_backend, env)

    shared_helpers_ast =
      generate_helpers_ast(:create_shared_helpers, transformed_routes_per_backend, env)

    {helpers_ast ++ shared_helpers_ast, transformed_routes_per_backend}
  end

  # Transformations

  def transform_routes_per_backend(backend_routes_callbacks, env) do
    Enum.map(backend_routes_callbacks, fn {backend, callbacks, routes} ->
      transformed_routes = apply_transform_callbacks(callbacks, routes, backend, env)
      {backend, callbacks, transformed_routes}
    end)
  end

  defp apply_transform_callbacks(callbacks, routes, backend, env) do
    Enum.reduce([:transform, :post_transform], routes, fn callback_name, acc ->
      extensions = callbacks[callback_name]
      apply_transform_callback(callback_name, extensions, acc, backend, env)
    end)
  end

  defp apply_transform_callback(callback_name, extensions, routes, backend, env) do
    Enum.reduce(extensions, routes, fn extension, inner_routes ->
      execute_callback(callback_name, backend, extension, [inner_routes, backend, env])
    end)
  end

  # AST Generation
  defp generate_helpers_ast(
         :create_helpers = callback_name,
         transformed_routes_per_backend,
         env
       ) do
    Enum.flat_map(transformed_routes_per_backend, fn {backend, callbacks, routes} ->
      extensions = callbacks[callback_name]
      generate_helper_ast(callback_name, extensions, routes, backend, env)
    end)
  end

  defp generate_helpers_ast(
         :create_shared_helpers = callback_name,
         transformed_routes_per_backend,
         env
       ) do
    routes_per_extension =
      transformed_routes_per_backend
      |> Enum.flat_map(fn {_backend, callbacks, routes} ->
        Enum.map(callbacks[callback_name], fn ext -> {ext, routes} end)
      end)
      |> Enum.group_by(
        fn {ext, _routes} -> ext end,
        fn {_ext, routes} -> routes end
      )

    Enum.flat_map(routes_per_extension, fn {ext, routes} ->
      routes = List.flatten(routes)
      backends = Routex.Route.get_backends(routes)
      extensions = [ext]
      generate_helper_ast(callback_name, extensions, routes, backends, env)
    end)
  end

  defp generate_helper_ast(callback_name, extensions, routes, backend, env) do
    Enum.map(extensions, fn extension ->
      ast =
        callback_name
        |> execute_callback(backend, extension, [routes, backend, env])
        |> dedup_ast()

      :ok = Macro.validate(ast)

      {extension, ast}
    end)
  end

  defp restore_routes_order(routes), do: Enum.sort_by(routes, &Attrs.get(&1, :__branch__))

  defp flatten_transformed_routes(transformed_routes_per_backend),
    do:
      Enum.flat_map(transformed_routes_per_backend, fn {_backend, _callbacks, routes} ->
        routes
      end)

  @spec create_helper_module(T.ast(), helper_module, T.env()) ::
          {:module, module, binary, term}
  defp create_helper_module(ast, module, env) do
    Utils.print(__MODULE__, ["Create or update helper module ", inspect(module)])
    Module.create(module, ast, env)
  end

  defp generate_helper_ast(ast_per_extension, module) do
    prelude =
      quote do
        require Logger
        use Routex.HelperFallbacks
      end

    helpers_ast =
      ast_per_extension
      |> Enum.map(fn {_ext, ast} -> ast end)
      |> dedup_ast()

    ast = [prelude, helpers_ast]

    if Application.fetch_env(:routex, :helper_mod_dir) != :error do
      sub_path = (module |> to_string() |> String.trim_leading("Elixir.")) <> ".ex"
      write_ast(ast, module, sub_path)
    end

    :ok = Macro.validate(ast)
    ast
  end

  defp write_ast(ast, module, sub_path) do
    dir = Application.fetch_env!(:routex, :helper_mod_dir)
    path = Path.join(dir, sub_path)

    Routex.Utils.print(__MODULE__, "Wrote AST of #{module} to #{path}")

    wrapped_ast =
      quote do
        defmodule unquote(module) do
          @moduledoc """
          This code is generated by Routex and is for inspection purpose only
          """

          unquote_splicing(ast)
        end
      end

    formatted_binary =
      wrapped_ast
      |> Macro.to_string()
      |> Code.format_string!()

    :ok = File.write(path, formatted_binary)
  end

  # prevent duplication of attributes, functions etc
  defp dedup_ast(ast) do
    ast
    |> List.wrap()
    |> List.flatten()
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
  end

  defp remove_build_info(routes) do
    Enum.map(routes, &Attrs.cleanup/1)
  end

  defp write_routes(routes, env) do
    Module.delete_attribute(env.module, :phoenix_routes)
    Module.register_attribute(env.module, :phoenix_routes, accumulate: true)
    Enum.each(routes, &Module.put_attribute(env.module, :phoenix_routes, &1))
  end

  @doc """
  Executes the specified callback for an extension and returns the result.
  """
  def execute_callback(callback, backend, extension_module, args) do
    postprint = [
      inspect(backend),
      " -> ",
      inspect(extension_module),
      ".",
      callback |> Atom.to_string() |> String.trim_leading(":"),
      "/",
      to_string(Enum.count(args))
    ]

    processing_print = "Executing: "
    complete_print = "Completed: "

    debug?() && IO.write([processing_print, postprint])
    result = apply(extension_module, callback, args)
    debug?() && IO.write(["\r", complete_print, postprint, "\n"])

    result
  end
end