Current section

Files

Jump to
localize lib localize number format.ex
Raw

lib/localize/number/format.ex

defmodule Localize.Number.Format do
@moduledoc """
Functions to manage the collection of number format patterns
defined in CLDR.
Number patterns affect how numbers are interpreted in a localized
context. A format pattern such as `"#,##0.###"` defines the decimal
and grouping separators, zero-padding, and digit positions. The
actual separator characters are determined by the locale's number
symbols.
Format data is retrieved at runtime from locale data via the
configured locale provider.
"""
alias Localize.Number.System
@short_format_styles [:decimal_long, :decimal_short, :currency_short, :currency_long]
@format_styles [
:standard,
:currency,
:accounting,
:scientific,
:percent,
:accounting_alpha_next_to_number,
:accounting_no_symbol,
:currency_alpha_next_to_number,
:currency_no_symbol
] ++ @short_format_styles
defstruct @format_styles ++ [:currency_spacing, :other, :rational]
@behaviour Access
@type format :: String.t()
@type t :: %__MODULE__{}
@impl Access
def fetch(struct, key), do: Map.fetch(struct, key)
@impl Access
def get_and_update(struct, key, function) do
current = Map.get(struct, key)
case function.(current) do
{get, update} -> {get, Map.put(struct, key, update)}
:pop -> {current, Map.delete(struct, key)}
end
end
@impl Access
def pop(struct, key) do
{Map.get(struct, key), Map.delete(struct, key)}
end
@reject_styles [:__struct__, :currency_spacing, :other, :rational]
@isnt_really_a_short_format [:currency_long]
@short_formats MapSet.new(@short_format_styles -- @isnt_really_a_short_format)
@doc """
Returns the short format style names.
### Returns
* A list of atoms: `[:decimal_long, :decimal_short, :currency_short, :currency_long]`.
"""
@spec short_format_styles() :: [
:decimal_long | :decimal_short | :currency_short | :currency_long,
...
]
def short_format_styles do
@short_format_styles
end
@doc """
Returns all number formats defined for a locale, keyed by
number system.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* `{:ok, formats_map}` where `formats_map` is a map of
`%{system_name => Localize.Number.Format.t()}`.
* `{:error, exception}` if the locale data cannot be loaded.
### Examples
iex> {:ok, formats} = Localize.Number.Format.all_formats_for(:en)
iex> Map.has_key?(formats, :latn)
true
"""
@spec all_formats_for(Localize.LanguageTag.t() | atom() | String.t()) ::
{:ok, map()} | {:error, Exception.t()}
def all_formats_for(locale) do
locale_id = to_locale_id(locale)
Localize.Locale.get(locale_id, [:number_formats])
end
@doc """
Same as `all_formats_for/1` but raises on error.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* A map of formats keyed by number system.
### Raises
* Raises an exception if the locale data cannot be loaded.
"""
@spec all_formats_for!(Localize.LanguageTag.t() | atom() | String.t()) :: map()
def all_formats_for!(locale) do
case all_formats_for(locale) do
{:ok, formats} -> formats
{:error, exception} -> raise exception
end
end
@doc """
Returns the format definitions for a given locale and number system.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
* `number_system` is a number system name or type atom.
The default is `:default`.
### Returns
* `{:ok, Localize.Number.Format.t()}` with the format definitions.
* `{:error, exception}` if the locale data cannot be loaded or
the number system is not available.
"""
@spec formats_for(
Localize.LanguageTag.t() | atom() | String.t(),
atom() | String.t()
) ::
{:ok, t()} | {:error, Exception.t()}
def formats_for(locale, number_system \\ :default) do
with {:ok, system_name} <- System.system_name_from(number_system, locale),
{:ok, formats} <- all_formats_for(locale) do
case Map.get(formats, system_name) do
nil ->
{:error,
Localize.InvalidValueError.exception(
value: number_system,
expected: "a number system with formats for this locale",
context: "Localize.Number.Format.formats_for/2"
)}
format ->
{:ok, format}
end
end
end
@doc """
Same as `formats_for/2` but raises on error.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
* `number_system` is a number system name or type atom.
The default is `:default`.
### Returns
* A `Localize.Number.Format.t()` struct.
### Raises
* Raises an exception if the locale or number system is invalid.
"""
@spec formats_for!(
Localize.LanguageTag.t() | atom() | String.t(),
atom() | String.t()
) :: t()
def formats_for!(locale, number_system \\ :default) do
case formats_for(locale, number_system) do
{:ok, formats} -> formats
{:error, exception} -> raise exception
end
end
@doc """
Returns the minimum grouping digits for a locale.
This determines the minimum number of digits before grouping
separators are applied. For example, if the minimum is 2, the
number 1000 would be formatted as "1000" rather than "1,000".
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* `{:ok, minimum_digits}` where `minimum_digits` is a
non-negative integer.
* `{:error, exception}` if the locale data cannot be loaded.
### Examples
iex> Localize.Number.Format.minimum_grouping_digits_for(:en)
{:ok, 1}
"""
@spec minimum_grouping_digits_for(Localize.LanguageTag.t() | atom() | String.t()) ::
{:ok, non_neg_integer()} | {:error, Exception.t()}
def minimum_grouping_digits_for(locale) do
locale_id = to_locale_id(locale)
Localize.Locale.get(locale_id, [:minimum_grouping_digits])
end
@doc """
Same as `minimum_grouping_digits_for/1` but raises on error.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* The minimum grouping digits as an integer.
### Raises
* Raises an exception if the locale data cannot be loaded.
### Examples
iex> Localize.Number.Format.minimum_grouping_digits_for!(:en)
1
"""
@spec minimum_grouping_digits_for!(Localize.LanguageTag.t() | atom() | String.t()) ::
non_neg_integer()
def minimum_grouping_digits_for!(locale) do
case minimum_grouping_digits_for(locale) do
{:ok, digits} -> digits
{:error, exception} -> raise exception
end
end
@doc """
Returns the default grouping definition for a locale.
The grouping defines how integer and fraction digits are
grouped with separators in the standard number format.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* `{:ok, grouping}` where `grouping` is a map with
`:integer` and `:fraction` keys, each containing
`:first` and `:rest` group sizes.
* `{:error, exception}` if the locale data cannot be loaded.
### Examples
iex> Localize.Number.Format.default_grouping_for(:en)
{:ok, %{fraction: %{first: 0, rest: 0}, integer: %{first: 3, rest: 3}}}
"""
@spec default_grouping_for(Localize.LanguageTag.t() | atom() | String.t()) ::
{:ok, map()} | {:error, Exception.t()}
def default_grouping_for(locale) do
with {:ok, formats} <- formats_for(locale, :default) do
standard_format = formats.standard || "#,##0.###"
grouping = extract_grouping(standard_format)
{:ok, grouping}
end
end
@doc """
Same as `default_grouping_for/1` but raises on error.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* A grouping map.
### Raises
* Raises an exception if the locale data cannot be loaded.
### Examples
iex> Localize.Number.Format.default_grouping_for!(:en)
%{fraction: %{first: 0, rest: 0}, integer: %{first: 3, rest: 3}}
"""
@spec default_grouping_for!(Localize.LanguageTag.t() | atom() | String.t()) ::
%{
fraction: %{first: 0, rest: 0},
integer: %{first: non_neg_integer(), rest: non_neg_integer()}
}
def default_grouping_for!(locale) do
case default_grouping_for(locale) do
{:ok, grouping} -> grouping
{:error, exception} -> raise exception
end
end
@doc """
Returns the currency spacing rules for a locale and number system.
Currency spacing defines how to insert whitespace between
a currency symbol and the adjacent number.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
* `number_system` is a number system name or type atom.
The default is `:default`.
### Returns
* A map with `:before_currency` and `:after_currency` keys,
each containing spacing rules.
* `{:error, exception}` if the locale data cannot be loaded.
"""
@spec currency_spacing(
Localize.LanguageTag.t() | atom() | String.t(),
atom() | String.t()
) :: map() | {:error, Exception.t()}
def currency_spacing(locale, number_system \\ :default) do
with {:ok, formats} <- formats_for(locale, number_system) do
Map.get(formats, :currency_spacing)
end
end
@doc """
Returns the format styles available for a locale and number system.
Format styles standardise access to formats for common use cases
such as `:standard`, `:currency`, `:accounting`, `:scientific`,
`:percent`, etc.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
* `number_system` is a number system name or type atom.
The default is `:default`.
### Returns
* `{:ok, styles}` where `styles` is a list of format
style atoms.
* `{:error, exception}` if the locale data cannot be loaded.
"""
@spec format_styles_for(
Localize.LanguageTag.t() | atom() | String.t(),
atom() | String.t()
) :: {:ok, [atom()]} | {:error, Exception.t()}
def format_styles_for(locale, number_system \\ :default) do
with {:ok, formats} <- formats_for(locale, number_system) do
styles =
formats
|> Map.from_struct()
|> Enum.reject(fn {k, v} -> is_nil(v) || k in @reject_styles end)
|> Enum.map(fn {k, _v} -> k end)
{:ok, styles}
end
end
@doc """
Returns the short format styles available for a locale and
number system.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
* `number_system` is a number system name or type atom.
The default is `:default`.
### Returns
* `{:ok, styles}` where `styles` is a list of short format
style atoms.
* `{:error, exception}` if the locale data cannot be loaded.
"""
@dialyzer {:nowarn_function, short_format_styles_for: 1}
@dialyzer {:nowarn_function, short_format_styles_for: 2}
@spec short_format_styles_for(
Localize.LanguageTag.t() | atom() | String.t(),
atom() | String.t()
) :: {:ok, [atom()]} | {:error, Exception.t()}
def short_format_styles_for(locale, number_system \\ :default) do
with {:ok, styles} <- format_styles_for(locale, number_system) do
short =
styles
|> MapSet.new()
|> MapSet.intersection(@short_formats)
|> MapSet.to_list()
{:ok, short}
end
end
@doc """
Returns the number system types available for a locale.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* `{:ok, types}` where `types` is a list of system type atoms
(e.g., `[:default, :native]`).
* `{:error, exception}` if the locale data cannot be loaded.
"""
@spec format_system_types_for(Localize.LanguageTag.t() | atom() | String.t()) ::
{:ok, [atom()]} | {:error, Exception.t()}
def format_system_types_for(locale) do
with {:ok, systems} <- System.number_systems_for(locale) do
{:ok, Map.keys(systems)}
end
end
@doc """
Returns the number system names available for a locale.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0` struct.
### Returns
* `{:ok, names}` where `names` is a list of unique number
system name atoms.
* `{:error, exception}` if the locale data cannot be loaded.
"""
@spec format_system_names_for(Localize.LanguageTag.t() | atom() | String.t()) ::
{:ok, [atom()]} | {:error, Exception.t()}
def format_system_names_for(locale) do
System.number_system_names_for(locale)
end
@doc """
Returns the miscellaneous number patterns for a locale and
number system.
These patterns include range, approximately, at-least, and
at-most formatting templates.
### Arguments
* `locale` is a locale identifier atom, string, or a
`t:Localize.LanguageTag.t/0`.
* `number_system` is a number system name atom. The default
is `:latn`.
### Returns
* `{:ok, patterns}` where `patterns` is a map with keys
`:range`, `:approximately`, `:at_least`, and `:at_most`.
* `{:error, exception}` if the locale or number system data
cannot be loaded.
### Examples
iex> {:ok, patterns} = Localize.Number.Format.misc_patterns_for(:en)
iex> patterns.range
[0, "–", 1]
"""
@spec misc_patterns_for(Localize.locale(), atom()) ::
{:ok, map()} | {:error, Exception.t()}
def misc_patterns_for(locale, number_system \\ :latn) do
locale_id = to_locale_id(locale)
Localize.Locale.get(locale_id, [:number_formats, number_system, :other])
end
# ── Private helpers ──────────────────────────────────────────
defp to_locale_id(locale), do: Localize.Locale.to_locale_id(locale)
# Extract grouping from a standard format pattern string.
# Parses patterns like "#,##0.###" or "#,##,##0.###".
defp extract_grouping(format_string) when is_binary(format_string) do
# Split on semicolon to get positive pattern only
positive = format_string |> String.split(";") |> hd()
# Split on decimal point
[integer_part | fraction_parts] = String.split(positive, ".")
integer_grouping = extract_integer_grouping(integer_part)
fraction_grouping = extract_fraction_grouping(List.first(fraction_parts))
%{integer: integer_grouping, fraction: fraction_grouping}
end
defp extract_integer_grouping(integer_part) do
# Split on commas and reverse so the group nearest the decimal
# point is first. For "#,##0" → ["##0", "#"]; for
# "#,##,##0" → ["##0", "##", "#"].
groups =
integer_part
|> String.split(",")
|> Enum.reverse()
case groups do
[_single] ->
%{first: 0, rest: 0}
[first_group] ++ rest_groups ->
first_size = String.length(first_group)
# The "rest" size is the second group if there are 3+ comma-
# separated segments (e.g., Indian "#,##,##0" → rest is 2).
# When there are only two segments, rest equals first (standard
# grouping like "#,##0" → both are 3).
rest_size =
case rest_groups do
[second | _more] when length(rest_groups) > 1 ->
String.length(second)
_ ->
first_size
end
%{first: first_size, rest: rest_size}
end
end
defp extract_fraction_grouping(nil), do: %{first: 0, rest: 0}
defp extract_fraction_grouping(_fraction_part), do: %{first: 0, rest: 0}
end