lib/localize/inputs/number/symbols.ex

defmodule Localize.Inputs.Number.Symbols do
  @moduledoc """
  Locale-derived display data for number form inputs.

  Returns the decimal/grouping separator characters, the active
  number system, and the locale's minus sign — everything a JS
  hook needs to render a number in the user's locale.

  All locale lookups go through `Localize.validate_locale/1`;
  the resulting `t:Localize.LanguageTag.t/0`'s
  `:cldr_locale_id` is the canonical id reported back. Number
  system resolution goes through
  `Localize.Number.System.number_system_from_locale/1` so
  Arabic-Indic, Persian, and other non-Latin digit systems work
  out of the box.

  """

  alias Localize.Inputs.Number.NoNumberSymbolsError
  alias Localize.LanguageTag
  alias Localize.Number.Symbol
  alias Localize.Number.System

  @typedoc """
  Locale display data resolved by `number_for_locale/1`.

  * `:locale` — the canonical CLDR locale id (atom).

  * `:language_tag` — the full `t:Localize.LanguageTag.t/0`.

  * `:number_system` — the active number system (`:latn`,
    `:arab`, `:arabext`, …).

  * `:decimal` — the locale's decimal separator character.

  * `:group` — the locale's grouping separator character.

  * `:minus_sign` — the locale's minus sign character.

  """
  @type t :: %__MODULE__{
          locale: atom(),
          language_tag: LanguageTag.t(),
          number_system: atom(),
          decimal: String.t(),
          group: String.t(),
          minus_sign: String.t()
        }

  defstruct [
    :locale,
    :language_tag,
    :number_system,
    :decimal,
    :group,
    :minus_sign
  ]

  @doc """
  Resolves display data for the given locale.

  ### Arguments

  * `locale` is a locale identifier (atom, string, or
    `t:Localize.LanguageTag.t/0`). Defaults to
    `Localize.get_locale/0`.

  ### Returns

  * `{:ok, t()}` on success.

  * `{:error, Exception.t()}` when the locale can't be parsed
    (`Localize.InvalidLocaleError`), the number system can't be
    resolved, or no symbols are available
    (`Localize.Inputs.Number.NoNumberSymbolsError`).

  ### Examples

      iex> {:ok, info} = Localize.Inputs.Number.Symbols.number_for_locale(:en)
      iex> {info.decimal, info.group, info.number_system}
      {".", ",", :latn}

      iex> {:ok, info} = Localize.Inputs.Number.Symbols.number_for_locale(:de)
      iex> {info.decimal, info.group}
      {",", "."}

  """
  @spec number_for_locale(LanguageTag.t() | atom() | String.t() | nil) ::
          {:ok, t()} | {:error, Exception.t()}
  def number_for_locale(locale \\ nil) do
    with {:ok, language_tag} <- Localize.validate_locale(locale || Localize.get_locale()),
         {:ok, number_system} <- System.number_system_from_locale(language_tag),
         {:ok, symbols} <- resolve_symbols(language_tag, number_system) do
      {:ok,
       %__MODULE__{
         locale: language_tag.cldr_locale_id,
         language_tag: language_tag,
         number_system: number_system,
         decimal: symbol_string(symbols.decimal),
         group: symbol_string(symbols.group),
         minus_sign: symbol_string(symbols.minus_sign)
       }}
    end
  end

  # ── internal ────────────────────────────────────────────────

  defp resolve_symbols(language_tag, number_system) do
    case Symbol.number_symbols_for(language_tag, number_system) do
      {:ok, symbols} ->
        {:ok, symbols}

      {:error, _} ->
        # CLDR doesn't always populate symbols for every
        # (locale, system) pair. Fall back to the locale's
        # *default* number system — the value at the `:default`
        # key of `Localize.Number.System.number_systems_for/1`
        # (e.g. `:latn` for `en`, `:arabext` for `fa`).
        with {:ok, %{default: default_system}} <- System.number_systems_for(language_tag),
             true <- default_system != number_system,
             {:ok, symbols} <- Symbol.number_symbols_for(language_tag, default_system) do
          {:ok, symbols}
        else
          _ ->
            {:error,
             NoNumberSymbolsError.exception(
               locale: language_tag.cldr_locale_id,
               number_system: number_system
             )}
        end
    end
  end

  # Number-symbol fields are either a plain binary or a map of
  # variants keyed by `:standard` / `:accounting` / etc. Every
  # locale CLDR ships has one of these two shapes (verified
  # empirically across ~70 locales). If a new shape ever appears
  # we want a loud `FunctionClauseError` so it surfaces rather
  # than silently returning garbage from `Map.values/1`.
  defp symbol_string(value) when is_binary(value), do: value
  defp symbol_string(%{standard: value}) when is_binary(value), do: value
end