lib/gettext/plural.ex

defmodule Gettext.Plural do
  @moduledoc """
  Behaviour and default implementation for finding plural forms in given
  locales.

  This module both defines the `Gettext.Plural` behaviour and provides a default
  implementation for it.

  ## Plural Forms

  > For a given language, there is a grammatical rule on how to change words
  > depending on the number qualifying the word. Different languages can have
  > different rules.
  [[source]](https://udn.realityripple.com/docs/Mozilla/Localization/Localization_and_Plurals)

  Such grammatical rules define a number of **plural forms**. For example,
  English has two plural forms: one for when there is just one element (the
  *singular*) and another one for when there are zero or more than one elements
  (the *plural*). There are languages which only have one plural form and there
  are languages which have more than two.

  In GNU Gettext (and in Gettext for Elixir), plural forms are represented by
  increasing 0-indexed integers. For example, in English `0` means singular and
  `1` means plural.

  The goal of this module is to determine, given a locale:

    * how many plural forms exist in that locale (`nplurals/1`);

    * to what plural form a given number of elements belongs to in that locale
      (`plural/2`).

  ## Default Implementation

  `Gettext.Plural` provides a default implementation of a plural module. Most
  common languages used on Earth should be covered by this default implementation. If
  custom pluralization rules are needed (for example, to add additional
  languages) a different plural module can be specified when creating a Gettext
  backend. For example, pluralization rules for the Elvish language could be
  added as follows:

      defmodule MyApp.Plural do
        @behaviour Gettext.Plural

        def nplurals("elv"), do: 3

        def plural("elv", 0), do: 0
        def plural("elv", 1), do: 1
        def plural("elv", _), do: 2

        # Fall back to Gettext.Plural
        defdelegate nplurals(locale), to: Gettext.Plural
        defdelegate plural(locale, n), to: Gettext.Plural
      end

  The mathematical expressions used in this module to determine the plural form
  of a given number of elements are taken from [this
  page](http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html#f2)
  as well as from [Mozilla's guide on "Localization and
  plurals"](https://udn.realityripple.com/docs/Mozilla/Localization/Localization_and_Plurals).

  ## Changing Implementations

  Once you have defined your custom plural forms module, you can use it
  in two ways. You can set it for all Gettext backends in your
  configuration:

      # For example, in config/config.exs
      config :gettext, :plural_forms, MyApp.Plural

  or you can set it for each specific backend when you call `use Gettext`:

      defmodule MyApp.Gettext do
        use Gettext, otp_app: :my_app, plural_forms: MyApp.Plural
      end

  > #### Compile-time Configuration {: .warning}
  >
  > Set `:plural_forms` in your `config/config.exs` and
  > not in `config/runtime.exs`, as Gettext reads this option when
  > compiling your backends.

  Task such as `mix gettext.merge` use the plural
  backend configured under the `:gettext` application, so in general
  the global configuration approach is preferred.

  Some tasks also allow the number of plural forms to be given
  explicitly, for example:

      mix gettext.merge priv/gettext --locale=gsw_CH --plural-forms=2

  ## Unknown Locales

  Trying to call `Gettext.Plural` functions with unknown locales will result in
  a `Gettext.Plural.UnknownLocaleError` exception.

  ## Language and Territory

  Often, a locale is composed as a language and territory pair, such as
  `en_US`. The default implementation for `Gettext.Plural` handles `xx_YY` by
  forwarding it to `xx` (except for *just Brazilian Portuguese*, `pt_BR`, which
  is not forwarded to `pt` as pluralization rules differ slightly). We treat the
  underscore as a separator according to
  [ISO 15897](https://en.wikipedia.org/wiki/ISO/IEC_15897). Sometimes, a dash `-` is
  used as a separator (for example [BCP47](https://en.wikipedia.org/wiki/IETF_language_tag)
  locales use this as in `en-US`): this is *not forwarded* to `en` in the default
  `Gettext.Plural` (and it will raise an `Gettext.Plural.UnknownLocaleError` exception
  if there are no messages for `en-US`). We recommend defining a custom plural forms
  module that replaces `-` with `_` if needed.

  ## Examples

  An example of the plural form of a given number of elements in the Polish
  language:

      iex> Gettext.Plural.plural("pl", 1)
      0
      iex> Gettext.Plural.plural("pl", 2)
      1
      iex> Gettext.Plural.plural("pl", 5)
      2
      iex> Gettext.Plural.plural("pl", 112)
      2

  As expected, `nplurals/1` returns the possible number of plural forms:

      iex> Gettext.Plural.nplurals("pl")
      3

  """

  alias Expo.Messages

  # Types

  @typedoc """
  A locale passed to `c:plural/2`.
  """
  @typedoc since: "0.22.0"
  @type locale() :: String.t()

  @typedoc """
  The context passed to the optional `c:init/1` callback.

  If `:plural_forms_header` is present, it contains the contents
  of the `Plural-Forms` Gettext header.
  """
  @typedoc since: "0.22.0"
  @type pluralization_context() :: %{
          required(:locale) => locale(),
          optional(:plural_forms_header) => String.t()
        }

  @typedoc """
  The term that the optional `c:init/1` callback returns.
  """
  @typedoc since: "0.22.0"
  @type plural_info() :: term()

  ## Behaviour definition

  @doc """
  Should initialize the context for `c:nplurals/1` and `c:plural/2`.

  This callback should perform all preparations for the provided locale, which
  is part of the pluralization context (see `t:pluralization_context/0`). For
  example, you can use this callback to parse the `Plural-Forms` header and
  determine pluralization rules for the locale.

  If defined, Gettext calls this callback *once* at compile time. If not defined,
  the returned `plural_info` will be equals to the locale found in
  `pluralization_context`.

  ## Examples

      defmodule MyApp.Plural do
        @behaviour Gettext.Plural

        @impl true
        def init(%{locale: _locale, plural_forms_header: header}) do
          {nplurals, rule} = parse_plural_forms_header(header)

          # This is what other callbacks can use to determine the plural.
          {nplurals, rule}
        end

        @impl true
        def nplurals({_locale, nplurals, _rule}), do: nplurals

        # ...
      end

  """
  @doc since: "0.22.0"
  @callback init(pluralization_context()) :: plural_info()

  @doc """
  Should return the number of possible plural forms in the given `locale`.
  """
  @callback nplurals(plural_info()) :: pos_integer()

  @doc """
  Should return the plural form in the given `locale` for the given `count` of
  elements.
  """
  @callback plural(plural_info(), count :: integer()) :: plural_form :: non_neg_integer()

  @doc """
  Should return the value of the `Plural-Forms` header for the given `locale`,
  if present.

  If the value of the `Plural-Forms` header is unavailable for any reason, this
  function should return `nil`.

  This callback is optional. If it's not defined, the fallback returns:

      "nplurals={nplurals};"

  """
  @doc since: "0.22.0"
  @callback plural_forms_header(locale()) :: String.t() | nil

  @optional_callbacks init: 1, plural_forms_header: 1

  defmodule UnknownLocaleError do
    @moduledoc """
    Raised when a pluralized module doesn't know how to handle a locale.

    ## Examples

        raise Gettext.Plural.UnknownLocaleError, "en-US"

    """

    defexception [:message]

    def exception(locale) when is_binary(locale) do
      message = """
      unknown locale #{inspect(locale)}. If this is a locale you need to handle,
      consider using a custom pluralizer module instead of the default
      Gettext.Plural. You can read more about this on the Gettext docs at
      https://hexdocs.pm/gettext/Gettext.Plural.html
      """

      %__MODULE__{message: message}
    end
  end

  # Behaviour implementation.

  defmacrop ends_in(n, digits) do
    digits = List.wrap(digits)

    quote do
      rem(unquote(n), 10) in unquote(digits)
    end
  end

  # Default implementation of the init/1 callback, in case the user uses
  # Gettext.Plural as their plural forms module.
  @doc false
  def init(context)

  def init(%{locale: locale, plural_forms_header: plural_forms_header}) do
    case Expo.PluralForms.parse(plural_forms_header) do
      {:ok, plural_forms} ->
        {locale, plural_forms}

      {:error, _reason} ->
        message_about_header =
          case Expo.PluralForms.plural_form(locale) do
            {:ok, plural_form} ->
              """

              For the #{inspect(locale)} locale, you can use the following header:

              #{Expo.PluralForms.to_string(plural_form)}
              """

            :error ->
              ""
          end

        # Fall back to parsing headers such as "nplurals=3", without the "plural=..." part.
        # TODO: remove this in v0.24.0
        with "nplurals=" <> rest <- String.trim(plural_forms_header),
             {plural_forms, _rest} <- Integer.parse(rest) do
          IO.warn("""
          Plural-Forms headers in the form "nplurals=<int>" (without the "plural=<rule>" part \
          following) are invalid and support for them will be removed in future Gettext \
          versions. Make sure to use a complete Plural-Forms header, which also specifies \
          the pluralization rules, or remove the Plural-Forms header completely. If you \
          do the latter, Gettext will use its built-in pluralization rules for the languages \
          it knows about (see Gettext.Plural).#{message_about_header}\
          """)

          {locale, plural_forms}
        else
          _other -> locale
        end
    end
  end

  def init(%{locale: locale}), do: locale

  # Number of plural forms.

  @doc """
  Default implementation of the `c:nplurals/1` callback.
  """
  def nplurals(locale)

  # TODO: this is a fallback for headers such as "nplurals=x", without "plural=...".
  # We should remove support for these at some point.
  def nplurals({_locale, nplurals}) when is_integer(nplurals) do
    nplurals
  end

  # If the nplurals was provided, we don't need to look at the locale.
  def nplurals({_locale, plural_forms}) do
    plural_forms.nplurals
  end

  def nplurals(locale) do
    case Expo.PluralForms.plural_form(locale) do
      {:ok, plural_form} -> plural_form.nplurals
      :error -> recall_if_territory_or_raise(locale, &nplurals/1)
    end
  end

  @doc """
  Default implementation of the `c:plural/2` callback.
  """
  def plural(locale, count)

  # TODO: this is a fallback for headers such as "nplurals=x", without "plural=...".
  # We should remove support for these at some point.
  def plural({locale, nplurals}, count) when is_integer(nplurals) do
    plural(locale, count)
  end

  def plural({_locale, plural_form}, count) do
    Expo.PluralForms.index(plural_form, count)
  end

  def plural(locale, count) do
    case Expo.PluralForms.plural_form(locale) do
      {:ok, plural_form} -> Expo.PluralForms.index(plural_form, count)
      :error -> recall_if_territory_or_raise(locale, &plural(&1, count))
    end
  end

  defp recall_if_territory_or_raise(locale, fun) do
    case String.split(locale, "_", parts: 2, trim: true) do
      [lang, _territory] -> fun.(lang)
      _other -> raise UnknownLocaleError, locale
    end
  end

  @doc false
  def plural_info(locale, messages_struct, plural_mod) do
    ensure_loaded!(plural_mod)

    if function_exported?(plural_mod, :init, 1) do
      pluralization_context =
        case IO.iodata_to_binary(Messages.get_header(messages_struct, "Plural-Forms")) do
          "" -> %{locale: locale}
          plural_forms -> %{locale: locale, plural_forms_header: plural_forms}
        end

      plural_mod.init(pluralization_context)
    else
      locale
    end
  end

  @doc false
  def plural_forms_header_impl(locale, messages_struct, plural_mod) do
    ensure_loaded!(plural_mod)

    plural_forms_header =
      if function_exported?(plural_mod, :plural_forms_header, 1) do
        plural_mod.plural_forms_header(locale)
      end

    if plural_forms_header do
      plural_forms_header
    else
      nplurals = plural_mod.nplurals(plural_info(locale, messages_struct, plural_mod))
      "nplurals=#{nplurals}"
    end
  end

  # TODO: remove when we depend on Elixir 1.12+
  if function_exported?(Code, :ensure_loaded!, 1) do
    defp ensure_loaded!(mod), do: Code.ensure_loaded!(mod)
  else
    defp ensure_loaded!(mod) do
      case Code.ensure_loaded(mod) do
        {:module, ^mod} ->
          mod

        {:error, reason} ->
          raise ArgumentError,
                "could not load module #{inspect(mod)} due to reason #{inspect(reason)}"
      end
    end
  end
end