lib/idiom.ex

defmodule Idiom do
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  import Idiom.Interpolation

  alias Idiom.Cache
  alias Idiom.Extract
  alias Idiom.Locales
  alias Idiom.Plural

  require Logger

  @external_resource "README.md"

  @doc false
  defdelegate child_spec(options), to: Idiom.Supervisor

  defdelegate direction(locale), to: Locales

  @type translate_opts() :: [
          namespace: String.t(),
          to: String.t(),
          fallback: String.t() | list(String.t()),
          count: integer() | float() | Decimal.t() | String.t(),
          plural: :cardinal | :ordinal,
          cache_table_name: atom()
        ]

  @doc """
  Alias of `t/3` for when you don't need any bindings.
  """
  @spec t(String.t() | list(String.t()), translate_opts()) :: String.t()
  def t(key_or_keys, opts) when is_list(opts), do: t(key_or_keys, %{}, opts)

  @doc """
  Translates a key into a target language.

  ## Examples

  ```elixir
  iex> Idiom.t("hello", to: "es")
  "hola"

  # With process-wide locale
  iex> Idiom.put_locale("fr")
  iex> Idiom.t("hello")
  "bonjour"

  # If neither `:to` option is provided nor `:lang` is set in the process, it will check the application configuration:
  # Given `config :idiom, default_lang: "en"` is set in the `config.exs` file:
  iex> Idiom.t("hello")
  "hello"

  # If a key does not exist in the target language, it will use the `:fallback` option:
  iex> Idiom.t("hello", to: "de", fallback: "fr")
  "bonjour"

  # If a key does not exist in the target language or the first fallback language:
  iex> Idiom.t("hello", to: "de", fallback: ["pl", "fr"])
  "bonjour"
  ```
  """

  @spec t(String.t() | list(String.t()), map(), translate_opts()) :: String.t()
  def t(key_or_keys, bindings \\ %{}, opts \\ []) do
    locale = Keyword.get(opts, :to) || get_locale()
    namespace = Keyword.get(opts, :namespace) || get_namespace()

    run_t(locale, namespace, key_or_keys, bindings, opts)
  end

  defp run_t(locale, namespace, key_or_keys, _binding, _opts)
       when is_nil(locale) or is_nil(namespace) do
    Logger.warning("""
    Idiom: Called `t/3` without a locale or namespace set. You can configure a default locale and namespace by adding

      config :idiom,
        default_locale: "en",
        default_namespace: "default"

    to your configuration.  
    Returning the key untranslated: #{fallback_message(key_or_keys)}
    """)

    fallback_message(key_or_keys)
  end

  defp run_t(locale, namespace, key_or_keys, bindings, opts) do
    fallback =
      Keyword.get(opts, :fallback) || Application.get_env(:idiom, :default_fallback)

    count = Keyword.get(opts, :count)
    plural = Keyword.get(opts, :plural)
    bindings = Map.put_new(bindings, :count, count)

    locale_resolve_hierarchy =
      [locale | List.wrap(fallback)]
      |> Enum.map(&Locales.get_hierarchy/1)
      |> List.flatten()
      |> Enum.uniq()

    lookup_keys =
      Enum.reduce(locale_resolve_hierarchy, [], fn locale, acc ->
        pluralised_keys =
          key_or_keys
          |> List.wrap()
          |> Enum.flat_map(fn key ->
            [
              {locale, namespace, key},
              {locale, namespace, "#{key}_#{Plural.get_suffix(locale, count, type: plural)}"}
            ]
          end)

        acc ++ pluralised_keys
      end)

    cache_table_name = Keyword.get(opts, :cache_table_name, Cache.default_table_name())

    lookup_keys
    |> Enum.find_value(fallback_message(key_or_keys), fn {locale, namespace, key} ->
      Cache.get_translation(locale, namespace, key, cache_table_name)
    end)
    |> interpolate(bindings)
  end

  @doc """
  Returns the locale that will be used by `t/3`.

  ## Examples

  ```elixir
  iex> Idiom.get_locale()
  "en-US"
  ```
  """
  @spec get_locale() :: String.t()
  def get_locale do
    Process.get(:idiom_locale) || Application.get_env(:idiom, :default_locale)
  end

  @doc """
  Sets the locale for the current process.

  ## Examples

  ```elixir
  iex> Idiom.put_locale("fr-FR")
  "fr-FR"
  ```
  """
  @spec put_locale(String.t()) :: String.t()
  def put_locale(locale) when is_binary(locale) do
    Process.put(:idiom_locale, locale)

    locale
  end

  @doc """
  Returns the namespace that will be used by `t/3`.

  ## Examples

  ```elixir
  iex> Idiom.get_namespace()
  "default"
  ```
  """
  @spec get_namespace() :: String.t()
  def get_namespace do
    Process.get(:idiom_namespace) || Application.get_env(:idiom, :default_namespace)
  end

  @doc """
  Sets the namespace for the current process.

  ## Examples

  ```elixir
  iex> Idiom.put_namespace("signup")
  "signup"
  ```
  """
  @spec put_namespace(String.t()) :: String.t()
  def put_namespace(namespace) when is_binary(namespace) do
    Process.put(:idiom_namespace, namespace)

    namespace
  end

  @doc false
  defmacro __using__(_opts) do
    quote unquote: false do
      defmacro t_extract(key, opts) do
        if Application.get_env(:idiom, :extracting?, false) and is_binary(key) do
          file = __CALLER__.file
          key = Extract.expand_to_binary(key, __CALLER__)
          namespace = opts |> Keyword.get(:namespace) |> Extract.expand_to_binary(__CALLER__)
          has_count? = Keyword.has_key?(opts, :count)
          plural_type = Keyword.get(opts, :plural, :cardinal)

          Idiom.Extract.add_key(%{
            file: file,
            key: key,
            namespace: namespace,
            has_count?: has_count?,
            plural_type: plural_type
          })
        end
      end

      defmacro t(key, opts) when is_list(opts) do
        quote do
          unquote(__MODULE__).t_extract(unquote(key), unquote(opts))

          Idiom.t(unquote(key), %{}, unquote(opts))
        end
      end

      defmacro t(key, bindings \\ Macro.escape(%{}), opts \\ Macro.escape([])) do
        quote do
          unquote(__MODULE__).t_extract(unquote(key), unquote(opts))

          Idiom.t(unquote(key), unquote(bindings), unquote(opts))
        end
      end
    end
  end

  defp fallback_message(key_or_keys)
  defp fallback_message(key) when is_binary(key), do: key
  defp fallback_message(keys) when is_list(keys), do: List.first(keys)
end