Current section

Files

Jump to
localize lib localize locale distance_trie.ex
Raw

lib/localize/locale/distance_trie.ex

defmodule Localize.Locale.DistanceTrie do
@moduledoc false
# A 3-level nested map implementing the ICU XLocaleDistance trie
# for locale distance lookups.
#
# The trie is keyed by {desired, supported} tuples at each level.
# Wildcard entries use :* and negative variable entries use
# {:not_in, variable} which are checked at lookup time.
@persistent_term_key {:localize, :distance_trie}
@doc false
@spec lookup(String.t(), atom(), atom(), String.t(), atom(), atom()) :: number()
def lookup(
desired_lang,
desired_script,
desired_territory,
supported_lang,
supported_script,
supported_territory
) do
{trie, variables} = ensure_loaded()
default_lang_node = Map.get(trie, {:*, :*}) || %{distance: 0, script: %{}}
# Language level
{lang_distance, lang_node} =
if desired_lang == supported_lang do
node =
cascade_lookup(trie, desired_lang, supported_lang, nil, nil, variables) ||
default_lang_node
{0, node}
else
node =
cascade_lookup(trie, desired_lang, supported_lang, nil, nil, variables) ||
default_lang_node
distance = node[:distance] || default_lang_node.distance
{distance, node}
end
# Script level — check lang_node's table, fall back to default
script_table = lang_node[:script] || %{}
default_script_table = default_lang_node[:script] || %{}
{script_distance, script_node} =
if desired_script == supported_script do
node =
cascade_lookup(script_table, desired_script, supported_script, nil, nil, variables) ||
cascade_lookup(
default_script_table,
desired_script,
supported_script,
nil,
nil,
variables
)
{0, node}
else
node =
cascade_lookup(script_table, desired_script, supported_script, nil, nil, variables) ||
cascade_lookup(
default_script_table,
desired_script,
supported_script,
nil,
nil,
variables
)
cond do
node && node[:distance] -> {node.distance, node}
node -> {default_script_table[{:*, :*}][:distance] || 0, node}
true -> {0, nil}
end
end
# Territory level
default_script_node =
Map.get(script_table, {:*, :*}) ||
Map.get(default_script_table, {:*, :*}) ||
%{distance: 0, territory: %{}}
territory_table = (script_node && script_node[:territory]) || %{}
default_territory_table = default_script_node[:territory] || %{}
territory_distance =
if desired_territory == supported_territory do
0
else
resolve_territory_distance(
territory_table,
default_territory_table,
desired_territory,
supported_territory,
variables
)
end
lang_distance + script_distance + territory_distance
end
defp resolve_territory_distance(territory_table, default_table, desired, supported, variables) do
case territory_cascade_lookup(territory_table, desired, supported, variables) do
nil ->
case territory_cascade_lookup(default_table, desired, supported, variables) do
nil -> 0
distance -> distance
end
distance ->
distance
end
end
# Territory-level cascade with variable-aware matching.
# Tries in order: exact → {desired, :*} → {:*, supported} → not_in rules → {:*, :*}
defp territory_cascade_lookup(table, desired, supported, variables) do
Map.get(table, {desired, supported}) ||
Map.get(table, {desired, :*}) ||
Map.get(table, {:*, supported}) ||
find_not_in_match(table, desired, supported, variables) ||
Map.get(table, {:*, :*})
end
# Check {:not_in, var} entries — both desired and supported must NOT be in the variable
defp find_not_in_match(table, desired, supported, variables) do
# Try concrete + not_in combinations first (more specific),
# then not_in + not_in (less specific)
find_not_in_concrete(table, desired, supported, variables) ||
find_not_in_both(table, desired, supported, variables)
end
# One side is a concrete territory, the other is {:not_in, var}
defp find_not_in_concrete(table, desired, supported, variables) do
# Check {desired, {:not_in, var}} — desired is concrete, supported must not be in var
result =
Enum.find_value(table, fn
{{^desired, {:not_in, var}}, distance} ->
set = Map.fetch!(variables, var)
if supported not in set, do: distance
_ ->
nil
end)
# Check {{:not_in, var}, supported} — supported is concrete, desired must not be in var
result ||
Enum.find_value(table, fn
{{{:not_in, var}, ^supported}, distance} ->
set = Map.fetch!(variables, var)
if desired not in set, do: distance
_ ->
nil
end)
end
# Both sides are {:not_in, var} or {:not_in, var} + :*
defp find_not_in_both(table, desired, supported, variables) do
Enum.find_value(table, fn
{{{:not_in, var_d}, {:not_in, var_s}}, distance} ->
desired_set = Map.fetch!(variables, var_d)
supported_set = Map.fetch!(variables, var_s)
if desired not in desired_set and supported not in supported_set do
distance
end
{{{:not_in, var}, :*}, distance} ->
set = Map.fetch!(variables, var)
if desired not in set, do: distance
{{:*, {:not_in, var}}, distance} ->
set = Map.fetch!(variables, var)
if supported not in set, do: distance
_ ->
nil
end)
end
# Cascade lookup for language and script levels (no variable matching needed)
defp cascade_lookup(table, desired, supported, _desired_extra, _supported_extra, _variables) do
Map.get(table, {desired, supported}) ||
Map.get(table, {desired, :*}) ||
Map.get(table, {:*, supported}) ||
Map.get(table, {:*, :*})
end
defp ensure_loaded do
# Not routed through DataLoader because build() itself calls
# DataLoader.load to fetch supplemental data, which would
# deadlock (GenServer calling itself). The check-then-act
# pattern here is safe: build() is idempotent and the worst
# case of a concurrent race is a redundant persistent_term.put.
case :persistent_term.get(@persistent_term_key, :not_loaded) do
:not_loaded ->
result = build()
:persistent_term.put(@persistent_term_key, result)
result
result ->
result
end
end
@doc false
def build do
data = Localize.SupplementalData.language_matching()
rules = Map.fetch!(data, :language_match)
variables = Map.fetch!(data, :match_variables)
{lang_rules, script_rules, territory_rules} = partition_rules(rules)
trie = %{}
trie = insert_language_rules(trie, lang_rules)
trie = insert_script_rules(trie, script_rules)
trie = insert_territory_rules(trie, territory_rules, variables)
{trie, variables}
end
defp partition_rules(rules) do
Enum.reduce(rules, {[], [], []}, fn rule, {lang, script, terr} ->
case length(rule.desired) do
1 -> {[rule | lang], script, terr}
2 -> {lang, [rule | script], terr}
3 -> {lang, script, [rule | terr]}
end
end)
|> then(fn {l, s, t} -> {Enum.reverse(l), Enum.reverse(s), Enum.reverse(t)} end)
end
# ── Language level ─────────────────────────────────────────
defp insert_language_rules(trie, rules) do
Enum.reduce(rules, trie, fn rule, acc ->
[desired_lang] = rule.desired
[supported_lang] = rule.supported
one_way = Map.get(rule, :one_way, false)
distance = rule.distance
acc = put_lang_entry(acc, desired_lang, supported_lang, distance)
if not one_way do
put_lang_entry(acc, supported_lang, desired_lang, distance)
else
acc
end
end)
end
defp put_lang_entry(trie, desired, supported, distance) do
key = {desired, supported}
Map.put_new(trie, key, %{distance: distance, script: %{}})
end
# ── Script level ───────────────────────────────────────────
defp insert_script_rules(trie, rules) do
Enum.reduce(rules, trie, fn rule, acc ->
[desired_lang, desired_script] = rule.desired
[supported_lang, supported_script] = rule.supported
one_way = Map.get(rule, :one_way, false)
distance = rule.distance
acc =
put_script_entry(
acc,
desired_lang,
supported_lang,
desired_script,
supported_script,
distance
)
if not one_way do
put_script_entry(
acc,
supported_lang,
desired_lang,
supported_script,
desired_script,
distance
)
else
acc
end
end)
end
defp put_script_entry(
trie,
desired_lang,
supported_lang,
desired_script,
supported_script,
distance
) do
lang_key = {desired_lang, supported_lang}
lang_node =
Map.get(trie, lang_key) || Map.get(trie, {desired_lang, :*}) || %{distance: 0, script: %{}}
script_key = {desired_script, supported_script}
script_table = Map.get(lang_node, :script, %{})
script_table = Map.put_new(script_table, script_key, %{distance: distance, territory: %{}})
updated_node = Map.put(lang_node, :script, script_table)
Map.put(trie, lang_key, updated_node)
end
# ── Territory level ────────────────────────────────────────
defp insert_territory_rules(trie, rules, variables) do
Enum.reduce(rules, trie, fn rule, acc ->
[desired_lang, desired_script, desired_terr] = rule.desired
[supported_lang, supported_script, supported_terr] = rule.supported
one_way = Map.get(rule, :one_way, false)
distance = rule.distance
desired_keys = expand_territory_keys(desired_terr, variables)
supported_keys = expand_territory_keys(supported_terr, variables)
acc =
for dk <- desired_keys, sk <- supported_keys, reduce: acc do
acc ->
put_territory_entry(
acc,
desired_lang,
supported_lang,
desired_script,
supported_script,
dk,
sk,
distance
)
end
if not one_way do
for dk <- supported_keys, sk <- desired_keys, reduce: acc do
acc ->
put_territory_entry(
acc,
supported_lang,
desired_lang,
supported_script,
desired_script,
dk,
sk,
distance
)
end
else
acc
end
end)
end
# Expand territory patterns to trie keys.
# {:in, var} expands to individual territory atoms.
# {:not_in, var} stays as-is (checked at lookup time).
# :* stays as :*.
defp expand_territory_keys(:*, _variables), do: [:*]
defp expand_territory_keys({:in, variable}, variables), do: Map.fetch!(variables, variable)
defp expand_territory_keys({:not_in, _variable} = not_in, _variables), do: [not_in]
defp expand_territory_keys(territory, _variables) when is_atom(territory), do: [territory]
defp put_territory_entry(
trie,
desired_lang,
supported_lang,
desired_script,
supported_script,
desired_terr,
supported_terr,
distance
) do
lang_key = {desired_lang, supported_lang}
lang_node =
Map.get(trie, lang_key) || Map.get(trie, {desired_lang, :*}) ||
%{distance: nil, script: %{}}
script_key = {desired_script, supported_script}
script_table = Map.get(lang_node, :script, %{})
script_node =
Map.get(script_table, script_key) || Map.get(script_table, {desired_script, :*}) ||
%{distance: nil, territory: %{}}
terr_key = {desired_terr, supported_terr}
territory_table = Map.get(script_node, :territory, %{})
territory_table = Map.put_new(territory_table, terr_key, distance)
updated_script_node = Map.put(script_node, :territory, territory_table)
updated_script_table = Map.put(script_table, script_key, updated_script_node)
updated_lang_node = Map.put(lang_node, :script, updated_script_table)
Map.put(trie, lang_key, updated_lang_node)
end
end