Packages

Elixir implementation of the Cldr Collation algorithm, providing language-aware string sorting and comparison. An opt-in NIF is provided for high performance collating.

Current section

Files

Jump to
ex_cldr_collation lib cldr collation options.ex
Raw

lib/cldr/collation/options.ex

defmodule Cldr.Collation.Options do
@moduledoc """
Cldr.Collation options corresponding to BCP47 -u- extension keys.
Supports both Elixir keyword list construction and parsing from
BCP47 locale strings (e.g., "en-u-co-phonebk-ks-level2").
"""
defstruct strength: :tertiary,
alternate: :non_ignorable,
backwards: false,
normalization: false,
case_level: false,
case_first: false,
numeric: false,
reorder: [],
max_variable: :punct,
type: :standard,
tailoring: nil,
backend: :default
@type strength :: :primary | :secondary | :tertiary | :quaternary | :identical
@type alternate :: :non_ignorable | :shifted
@type case_first_opt :: :upper | :lower | false
@type max_variable :: :space | :punct | :symbol | :currency
@type backend :: :default | :nif | :elixir
@type t :: %__MODULE__{
strength: strength(),
alternate: alternate(),
backwards: boolean(),
normalization: boolean(),
case_level: boolean(),
case_first: case_first_opt(),
numeric: boolean(),
reorder: [atom()],
max_variable: max_variable(),
type: atom(),
tailoring: map() | nil,
backend: backend()
}
@doc """
Create a new options struct from a keyword list.
### Arguments
* `options` - a keyword list of collation options (default: `[]`).
### Options
* `:strength` - `:primary`, `:secondary`, `:tertiary` (default), `:quaternary`, or `:identical`.
* `:alternate` - `:non_ignorable` (default) or `:shifted`.
* `:backwards` - `false` (default) or `true`.
* `:normalization` - `false` (default) or `true`.
* `:case_level` - `false` (default) or `true`.
* `:case_first` - `false` (default), `:upper`, or `:lower`.
* `:numeric` - `false` (default) or `true`.
* `:reorder` - a script code atom or list of script code atoms (default: `[]`).
* `:max_variable` - `:space`, `:punct` (default), `:symbol`, or `:currency`.
* `:type` - `:standard` (default), `:search`, `:phonebook`, etc.
* `:ignore_accents` - `true` to ignore accent differences (sets `strength: :primary`).
Explicit `:strength` takes precedence.
* `:ignore_case` - `true` to ignore case differences (sets `strength: :secondary`).
Explicit `:strength` takes precedence.
* `:ignore_punctuation` - `true` to ignore punctuation and whitespace.
(sets `strength: :tertiary, alternate: :shifted`). Explicit `:strength` or
`:alternate` take precedence.
* `:casing` - `:sensitive` (default tertiary strength) or `:insensitive` (secondary strength).
This is a convenience option compatible with the `ex_cldr_collation` API. When provided,
it sets the `:strength` option accordingly.
* `:backend` - `:default` (use NIF if available, default), `:nif` (require NIF), or `:elixir` (pure Elixir only).
### Returns
A `%Cldr.Collation.Options{}` struct.
### Examples
iex> Cldr.Collation.Options.new()
%Cldr.Collation.Options{strength: :tertiary, alternate: :non_ignorable}
iex> Cldr.Collation.Options.new(strength: :primary, alternate: :shifted)
%Cldr.Collation.Options{strength: :primary, alternate: :shifted}
"""
@spec new(keyword()) :: t()
def new(options \\ []) do
options =
options
|> resolve_ignore_accents()
|> resolve_ignore_case()
|> resolve_ignore_punctuation()
|> resolve_casing()
|> Keyword.update(:reorder, [], &List.wrap/1)
struct(__MODULE__, options)
end
# `ignore_accents: true` sets `strength: :primary` (ignores both accents and case).
# Explicit `strength` takes precedence.
defp resolve_ignore_accents(options) do
case Keyword.pop(options, :ignore_accents) do
{true, options} ->
if Keyword.has_key?(options, :strength),
do: options,
else: Keyword.put(options, :strength, :primary)
{_, options} ->
options
end
end
# `ignore_case: true` sets `strength: :secondary` (ignores case but not accents).
# Explicit `strength` takes precedence.
defp resolve_ignore_case(options) do
case Keyword.pop(options, :ignore_case) do
{true, options} ->
if Keyword.has_key?(options, :strength),
do: options,
else: Keyword.put(options, :strength, :secondary)
{_, options} ->
options
end
end
# `ignore_punctuation: true` sets `strength: :tertiary` and `alternate: :shifted`.
# Explicit `strength` or `alternate` take precedence.
defp resolve_ignore_punctuation(options) do
case Keyword.pop(options, :ignore_punctuation) do
{true, options} ->
options
|> then(fn opts ->
if Keyword.has_key?(opts, :strength),
do: opts,
else: Keyword.put(opts, :strength, :tertiary)
end)
|> then(fn opts ->
if Keyword.has_key?(opts, :alternate),
do: opts,
else: Keyword.put(opts, :alternate, :shifted)
end)
{_, options} ->
options
end
end
# Map the `casing` convenience option to the equivalent `strength` setting.
# `casing: :insensitive` sets `strength: :secondary` (ignores case).
# `casing: :sensitive` leaves the default tertiary strength (case-sensitive).
# If both `casing` and `strength` are provided, `strength` takes precedence.
defp resolve_casing(options) do
case Keyword.pop(options, :casing) do
{nil, options} ->
options
{:insensitive, options} ->
if Keyword.has_key?(options, :strength) do
options
else
Keyword.put(options, :strength, :secondary)
end
{:sensitive, options} ->
options
{other, _options} ->
raise ArgumentError,
"invalid casing option #{inspect(other)}, expected :sensitive or :insensitive"
end
end
@doc """
Parse collation options from a BCP47 locale string with `-u-` extension.
Extracts collation-related keys from the Unicode locale extension subtag and
applies locale-specific defaults and tailoring rules. Supported BCP47 keys:
`co`, `ks`, `ka`, `kb`, `kk`, `kc`, `kf`, `kn`, `kr`, `kv`.
Option precedence (highest to lowest): BCP47 `-u-` keys > locale defaults >
tailoring rule overrides > struct defaults.
### Arguments
* `locale` - a BCP47 locale string (e.g., `"en-u-co-phonebk-ks-level2-ka-shifted"`).
### Returns
A `%Cldr.Collation.Options{}` struct with parsed values, locale defaults, and a
tailoring overlay (if available for the locale). Unrecognized keys are ignored
and defaults are used for missing keys.
### Examples
iex> options = Cldr.Collation.Options.from_locale("en-u-ks-level2")
iex> options.strength
:secondary
iex> options = Cldr.Collation.Options.from_locale("da")
iex> options.case_first
:upper
"""
@spec from_locale(String.t()) :: t()
def from_locale(locale) when is_binary(locale) do
alias Cldr.Collation.Tailoring
alias Cldr.Collation.Tailoring.LocaleDefaults
language = LocaleDefaults.extract_language(locale)
locale_defaults = LocaleDefaults.options_for(locale)
u_pairs = extract_u_extension(locale)
bcp47_opts = if u_pairs, do: u_pairs_to_opts(u_pairs), else: []
# Determine collation type from BCP47 keys
type = Keyword.get(bcp47_opts, :type, LocaleDefaults.default_type(locale))
# Load tailoring overlay if available
{tailoring_overlay, tailoring_option_overrides} =
case Tailoring.get_tailoring(language, type) do
{overlay, overrides} -> {overlay, overrides}
nil -> {nil, []}
end
# Build options with precedence: BCP47 > locale defaults > tailoring overrides > struct defaults
# Apply in reverse precedence order so higher priority overwrites lower
new()
|> struct(tailoring_option_overrides)
|> struct(locale_defaults)
|> struct(bcp47_opts)
|> Map.put(:tailoring, tailoring_overlay)
end
# Convert parsed u-pairs to keyword options
defp u_pairs_to_opts(pairs) do
Enum.reduce(pairs, [], fn {key, value}, acc ->
case key do
"co" -> [{:type, parse_type(value)} | acc]
"ks" -> [{:strength, parse_strength(value)} | acc]
"ka" -> [{:alternate, parse_alternate(value)} | acc]
"kb" -> [{:backwards, parse_bool(value)} | acc]
"kk" -> [{:normalization, parse_bool(value)} | acc]
"kc" -> [{:case_level, parse_bool(value)} | acc]
"kf" -> [{:case_first, parse_case_first(value)} | acc]
"kn" -> [{:numeric, parse_bool(value)} | acc]
"kr" -> [{:reorder, value |> String.split("-") |> Enum.map(&String.to_atom/1)} | acc]
"kv" -> [{:max_variable, parse_max_variable(value)} | acc]
_ -> acc
end
end)
end
defp extract_u_extension(locale) do
parts = String.split(locale, "-")
# Find the -u- extension
case Enum.find_index(parts, &(&1 == "u")) do
nil ->
nil
idx ->
# Collect key-value pairs after -u- until next extension or end
parts
|> Enum.drop(idx + 1)
|> collect_pairs([])
end
end
defp collect_pairs([], acc), do: Enum.reverse(acc)
# Stop at next extension singleton (single letter that's not a value)
defp collect_pairs([<<c::utf8>> | _rest], acc)
when c in ?a..?z and c != ?u do
Enum.reverse(acc)
end
defp collect_pairs([key, value | rest], acc) when byte_size(key) == 2 do
collect_pairs(rest, [{key, value} | acc])
end
defp collect_pairs([key | rest], acc) when byte_size(key) == 2 do
# Key with boolean true implied
collect_pairs(rest, [{key, "true"} | acc])
end
defp collect_pairs([value | rest], acc) do
# Multi-value continuation (e.g., kr-latn-grek)
# Append to previous pair's value
case acc do
[{key, prev_val} | acc_rest] ->
collect_pairs(rest, [{key, prev_val <> "-" <> value} | acc_rest])
_ ->
collect_pairs(rest, acc)
end
end
defp parse_strength("level1"), do: :primary
defp parse_strength("level2"), do: :secondary
defp parse_strength("level3"), do: :tertiary
defp parse_strength("level4"), do: :quaternary
defp parse_strength("identic"), do: :identical
defp parse_strength(_), do: :tertiary
defp parse_alternate("noignore"), do: :non_ignorable
defp parse_alternate("shifted"), do: :shifted
defp parse_alternate(_), do: :non_ignorable
defp parse_bool("true"), do: true
defp parse_bool("false"), do: false
defp parse_bool(_), do: false
defp parse_case_first("upper"), do: :upper
defp parse_case_first("lower"), do: :lower
defp parse_case_first("false"), do: false
defp parse_case_first(_), do: false
defp parse_max_variable("space"), do: :space
defp parse_max_variable("punct"), do: :punct
defp parse_max_variable("symbol"), do: :symbol
defp parse_max_variable("currency"), do: :currency
defp parse_max_variable(_), do: :punct
defp parse_type("standard"), do: :standard
defp parse_type("search"), do: :search
defp parse_type("phonebk"), do: :phonebook
defp parse_type("pinyin"), do: :pinyin
defp parse_type("stroke"), do: :stroke
defp parse_type("unihan"), do: :unihan
defp parse_type("zhuyin"), do: :zhuyin
defp parse_type("searchjl"), do: :searchjl
defp parse_type("eor"), do: :eor
defp parse_type("trad"), do: :traditional
defp parse_type("tradnl"), do: :traditional
defp parse_type(other), do: String.to_atom(other)
@doc """
Return the maximum primary weight that counts as "variable" for the given setting.
These boundaries come from the FractionalUCA.txt top_byte groupings. Variable
elements at or below this primary weight threshold are affected by the
`:alternate` setting in shifted mode.
### Arguments
* `options` - a `%Cldr.Collation.Options{}` struct.
### Returns
A non-negative integer representing the maximum primary weight boundary:
* `:space` - `0x0209`.
* `:punct` - `0x0B61`.
* `:symbol` - `0x0EE3`.
* `:currency` - `0x0EFF`.
### Examples
iex> Cldr.Collation.Options.max_variable_primary(%Cldr.Collation.Options{max_variable: :punct})
0x0B61
iex> Cldr.Collation.Options.max_variable_primary(%Cldr.Collation.Options{max_variable: :space})
0x0209
"""
@spec max_variable_primary(t()) :: non_neg_integer()
def max_variable_primary(%__MODULE__{max_variable: :space}), do: 0x0209
def max_variable_primary(%__MODULE__{max_variable: :punct}), do: 0x0B61
def max_variable_primary(%__MODULE__{max_variable: :symbol}), do: 0x0EE3
def max_variable_primary(%__MODULE__{max_variable: :currency}), do: 0x0EFF
@doc """
Returns whether the given options can be handled by the NIF backend.
The NIF backend supports all ICU-configurable collation attributes:
strength (all 5 levels), backwards, alternate, case_first, case_level,
normalization, numeric collation, and script reordering (for recognized
script codes).
Options that require the pure Elixir backend: `tailoring` (locale-specific
rules), non-default `max_variable` (fragile across ICU versions), and
reorder lists containing unrecognized script codes.
### Arguments
* `options` - a `%Cldr.Collation.Options{}` struct.
### Returns
* `true` if the NIF backend can handle these options.
* `false` if the pure Elixir backend is required.
### Examples
iex> Cldr.Collation.Options.nif_compatible?(%Cldr.Collation.Options{})
true
iex> Cldr.Collation.Options.nif_compatible?(%Cldr.Collation.Options{numeric: true})
true
iex> Cldr.Collation.Options.nif_compatible?(%Cldr.Collation.Options{reorder: [:Grek]})
true
"""
@spec nif_compatible?(t()) :: boolean()
def nif_compatible?(%__MODULE__{} = options) do
Cldr.Collation.Nif.reorder_codes_supported?(options.reorder) and
options.max_variable == :punct and
options.tailoring == nil
end
end