lib/localized_routes/helpers.ex

defmodule PhxLocalizedRoutes.Helpers do
  @moduledoc """
  Helpers to be used in views and controllers.
  """
  alias PhxLocalizedRoutes.Helpers.Private
  alias PhxLocalizedRoutes.Scope

  @doc ~S"""
  Marco used to wrap a Phoenix route and transform it into a localized
  route. The localized routes use the `:scope_helper` to alter
  the destination (alias) of the route on render.

  By default it uses the `scope_helper` from `Phoenix.LiveView.Socket` or `Plug.Conn`, keeping
  the user in it's current locale / scope. A custom `:scope_helper` can be provided through
  assigns in `loc_opts`.

  **Example**

  ```elixir
  Routes.product_index_path(@socket, :index)
  /products

  loc_opts = %{assigns: %{scope_helper: "eu_nl"}}
  loc_route(Routes.product_index_path(@socket, :index), loc_opts)
  /eu/nl/products
  ```

  When no `:scope_helper` is found or when no matching helper function is exported, an
  error is logged and the original link will be returned.

  **Example: Generate links to all other localized routes of the product index**

  ```elixir
  <!-- ExampleWeb.LocalizedRoutes is aliased as Loc in view_helpers() -->
  <!-- loc_route is imported from PhxLocalizedRoutes.Helpers -->
  <%= for {slug, opts} <- Loc.scopes(), opts.assigns.scope_helper != @loc.scope_helper do %>
      <span>
        <%= link " [#{slug}] ", to: loc_route(Routes.product_index_path(@socket, :index), opts) %></span>
  <% end %>
  ```
  """
  @spec loc_route(orig_route :: Macro.t(), loc_opts :: Scope.Flat.t() | nil) ::
          Macro.output()
  defmacro loc_route(orig_route, loc_opts \\ nil) do
    {helper_module, orig_helper_fn, conn_or_socket, args} = Private.fetch_vars(orig_route)

    quote bind_quoted: [
            orig_route: orig_route,
            helper_module: helper_module,
            orig_helper_fn: orig_helper_fn,
            conn_or_socket: conn_or_socket,
            args: args,
            loc_opts: loc_opts
          ] do
      scope = Private.get_scope_helper(loc_opts || conn_or_socket)

      case Private.localize_route(helper_module, orig_helper_fn, args, scope) do
        {:ok, :original} ->
          orig_route

        {:ok, loc_route} ->
          loc_route

        {:error, msg} ->
          Private.log_error(msg)
          orig_route
      end
    end
  end
end

defmodule PhxLocalizedRoutes.Helpers.Private do
  @moduledoc false

  alias Phoenix.LiveView.Socket
  alias PhxLocalizedRoutes.Scope
  alias Plug.Conn

  require Logger

  def localize_route(_helper_module, _orig_helper_fn, _args, nil = _scope),
    do: {:ok, :original}

  def localize_route(helper_module, orig_helper_fn, args, scope) do
    # There is no guarantee the helper function exists nor that the function
    # accepts the arguments passed into it. Therefor we catch any ArgumentError
    # and rescue with the original function.

    helper_fn = helper_fn(orig_helper_fn, scope)

    if fn_exists?(helper_module, helper_fn, args) do
      try do
        {:ok, apply(helper_module, helper_fn, args)}
      rescue
        ArgumentError ->
          {:error, "Failed to apply #{helper_module}.#{helper_fn}() with #{inspect(args)}"}
      end
    else
      {:error, "#{helper_module}.#{helper_fn} does not exist"}
    end
  end

  def get_scope_helper(%Scope.Flat{assign: %{scope_helper: helper}}),
    do: helper

  def get_scope_helper(%Socket{assigns: %{__assigns__: %{loc: %{scope_helper: helper}}}}),
    do: helper

  def get_scope_helper(%Conn{assigns: %{loc: %{scope_helper: helper}}}), do: helper

  def get_scope_helper(unmatched) do
    Logger.warning("`get_scope_helper/1` could not find a scope. Returns `nil`")

    Logger.debug(
      "`get_scope_helper/1` did not find key :scope_helper in:\n\n#{inspect(unmatched, limit: :infinity, structs: false)}\n"
    )

    nil
  end

  def fetch_vars(
        {{_marker, _meta, [helper_module, orig_helper_fn]}, _meta2,
         [conn_or_socket | _rest] = args}
      ) do
    {helper_module, orig_helper_fn, conn_or_socket, args}
  end

  @doc """
    Given the original helper function name and a string prefix returns an
    alternative helper function name. When the alternative helper function does
    not exists the original function name is returned.

    ## Examples:

      iex> PhxLocalizedRoutes.Helpers.Private.helper_fn(:page, "europe_nl")
      :europe_nl_page
  """

  def helper_fn(orig_helper_fn, nil), do: orig_helper_fn

  def helper_fn(orig_helper_fn, scope) when is_binary(scope) do
    str_original = Atom.to_string(orig_helper_fn)
    String.to_existing_atom(scope <> "_" <> str_original)
  rescue
    ArgumentError ->
      orig_helper_fn
  end

  # Wrapped Logger so no import is needed in the macro module
  def log_error(message) do
    Logger.error(message)
  end

  def fn_exists?(mod, func, args) do
    # using ultra fast pattern matching, fallback to slower length/1
    arity =
      case args do
        [_, _] -> 2
        [_, _, _] -> 3
        [_, _, _, _] -> 4
        [_ | _] = unmatched -> return_unmatched(unmatched)
      end

    Kernel.function_exported?(mod, func, arity)
  end

  def return_unmatched(unmatched) when is_list(unmatched),
    do:
      unmatched
      |> length
      |> tap(&Logger.warn("Unmatched arity of #{&1} used in #{__MODULE__}"))
end