Skip to content

Commit fd352f1

Browse files
committed
display_name + LocaleDisplay: validate via validate_locale, process via canonical_locale_id; fix -u-rg- subdivision override
1 parent c9a94e6 commit fd352f1

9 files changed

Lines changed: 193 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.41.3] — July 1st, 2026
8+
9+
### Changed
10+
11+
* `Localize.LanguageTag`'s `canonical_locale_id` field — and therefore `Localize.LanguageTag.to_string/1` — now holds the *canonical-syntax* form of the requested locale (aliases resolved and subtags ordered, but neither maximized nor minimized), so `"en"` stays `"en"` and `"en-US"` stays `"en-US"` instead of collapsing to the minimal identity form. Locale identity and data lookup are unaffected; they key off `cldr_locale_id`.
12+
13+
* `Localize.Language.display_name/2` now follows the TR35 display-name algorithm, which canonicalizes rather than adds likely subtags: a bare language keeps its own name (`"en"``"English"`, `"es"``"Spanish"`), while an explicitly supplied region or script still resolves to the region-specific CLDR name (`"en-GB"``"British English"`, `"pt-BR"``"Brazilian Portuguese"`). A string and an equivalent `Localize.LanguageTag` return the same result.
14+
15+
* `Localize.Language.display_name/2` and `Localize.Locale.LocaleDisplay.display_name/2` both now validate their input through `Localize.validate_locale/1` and drive the display off `canonical_locale_id`, so a string and an equivalent (maximized) `Localize.LanguageTag` render identically and no likely subtags leak into the output (a validated `"en"` tag is `"English"`, not `"English (United States)"`).
16+
17+
### Fixed
18+
19+
* `Localize.Language.display_name/2` and `Localize.Locale.LocaleDisplay.display_name/2` now break the two-subtag candidate tie toward the earlier subtag per TR35, trying `lang-script` before `lang-region`.
20+
21+
* `Localize.validate_locale/1` now accepts a `-u-rg-` region override that uses a subdivision id (e.g. `"en-u-rg-gbeng"`). The subdivision value was being suffixed with the `"zzzz"` region filler and rejected as an unknown locale.
22+
723
## [0.41.2] — July 1st, 2026
824

925
### Bug Fixes

lib/localize/language.ex

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@ defmodule Localize.Language do
8888
iex> Localize.Language.display_name("ja")
8989
{:ok, "Japanese"}
9090
91+
A bare language keeps its own name; an explicit region resolves to
92+
the region-specific name where CLDR defines one (per TR35 the display
93+
lookup canonicalizes but does not add likely subtags):
94+
95+
iex> Localize.Language.display_name("en")
96+
{:ok, "English"}
97+
98+
iex> Localize.Language.display_name("en-US")
99+
{:ok, "American English"}
100+
91101
"""
92102
@spec display_name(String.t() | LanguageTag.t(), Keyword.t()) ::
93103
{:ok, String.t()} | {:error, Exception.t()}
@@ -240,19 +250,43 @@ defmodule Localize.Language do
240250
# Builds candidate CLDR language keys from the most specific subtag
241251
# combination to the least, mirroring the CLDR locale display name
242252
# fallback order used by `Localize.Locale.LocaleDisplay`:
243-
# lang-script-territory → lang-territory → lang-script → lang. The
244-
# subtags are taken from the maximized (likely-subtags) language tag,
245-
# so `"pt"` resolves through `pt-BR` and `"en-GB"` through `en-GB`.
246-
defp candidate_codes(%LanguageTag{language: language, script: script, territory: territory}) do
253+
# lang-script-territory → lang-script → lang-territory → lang. Per
254+
# TR35 the tie between the two-subtag forms is broken in favour of the
255+
# subtag matching earlier in the identifier, so `lang-script` is tried
256+
# before `lang-territory`.
257+
#
258+
# The subtags are taken from the canonical-syntax form carried in
259+
# `canonical_locale_id` — the caller's request with aliases resolved
260+
# but no likely-subtags added — rather than the maximized struct
261+
# fields. Per TR35 the display algorithm canonicalizes but does not
262+
# maximize, so bare `"en"` resolves to "English" (not "American
263+
# English") while an explicit `"en-GB"` still resolves to "British
264+
# English". Falls back to the maximized fields when no canonical id is
265+
# present (e.g. a hand-built tag).
266+
defp candidate_codes(%LanguageTag{} = tag) do
267+
{language, script, territory} = display_subtags(tag)
268+
247269
[
248270
code_from(language, script, territory),
249-
code_from(language, nil, territory),
250271
code_from(language, script, nil),
272+
code_from(language, nil, territory),
251273
code_from(language, nil, nil)
252274
]
253275
|> Enum.uniq()
254276
end
255277

278+
defp display_subtags(%LanguageTag{canonical_locale_id: id} = tag) when is_binary(id) do
279+
case LanguageTag.parse(id) do
280+
{:ok, %LanguageTag{language: language, script: script, territory: territory}} ->
281+
{language, script, territory}
282+
283+
_ ->
284+
{tag.language, tag.script, tag.territory}
285+
end
286+
end
287+
288+
defp display_subtags(%LanguageTag{} = tag), do: {tag.language, tag.script, tag.territory}
289+
256290
defp code_from(language, script, territory) do
257291
[language, script, territory]
258292
|> Enum.reject(&is_nil/1)

lib/localize/language_tag.ex

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -341,11 +341,14 @@ defmodule Localize.LanguageTag do
341341
@doc """
342342
Create a fully resolved language tag from a locale string.
343343
344-
Parses the input, canonicalizes extensions, adds likely subtags
345-
to populate missing fields, then computes the minimized
346-
canonical locale name via remove likely subtags. The resulting
347-
struct has all fields populated but the `canonical_locale_id`
348-
is the shortest unambiguous form.
344+
Parses the input, canonicalizes extensions, and adds likely
345+
subtags to populate the `language`, `script`, and `territory`
346+
fields with the maximized resolution. The `canonical_locale_id`
347+
is set to the *canonical syntax* of the caller's request — the
348+
input with aliases replaced and subtags ordered, but neither
349+
maximized nor minimized — so `"en"` stays `"en"` and `"en-US"`
350+
stays `"en-US"`. Locale identity and data lookup key off
351+
`cldr_locale_id`, not `canonical_locale_id`.
349352
350353
### Arguments
351354
@@ -368,15 +371,25 @@ defmodule Localize.LanguageTag do
368371
iex> tag.territory
369372
:TW
370373
iex> tag.canonical_locale_id
371-
"zh-Hant"
374+
"zh-TW"
372375
373376
"""
374377
@spec new(String.t()) :: {:ok, t()} | {:error, term()}
375378
def new(locale_id) when is_binary(locale_id) do
376379
with {:ok, parsed} <- parse(locale_id),
377380
{:ok, canonical} <- canonicalize(parsed),
378381
{:ok, resolved} <- remove_likely_subtags(canonical) do
379-
tag = resolve_cldr_locale(resolved)
382+
# `remove_likely_subtags/1` maximizes the subtag fields but leaves
383+
# `canonical_locale_id` set to the minimized form. Restore the
384+
# canonical-syntax id computed by `canonicalize/1` so the field
385+
# faithfully reflects the caller's request (`en` stays `en`,
386+
# `en-US` stays `en-US`) rather than collapsing to the minimal
387+
# identity form. Locale identity/data lookup keys off
388+
# `cldr_locale_id`, not this field.
389+
tag =
390+
%{resolved | canonical_locale_id: canonical.canonical_locale_id}
391+
|> resolve_cldr_locale()
392+
380393
{:ok, tag}
381394
end
382395
end

lib/localize/locale/locale_display.ex

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,49 @@ defmodule Localize.Locale.LocaleDisplay do
6363
"""
6464
@spec display_name(Localize.LanguageTag.t() | String.t() | atom(), display_options()) ::
6565
{:ok, String.t()} | {:error, Exception.t()}
66-
def display_name(language_tag, options \\ [])
66+
def display_name(locale, options \\ []) do
67+
# Validate/normalize every input through the one canonical path
68+
# (`Localize.validate_locale/1`), then drive the display off the
69+
# canonical-syntax subtags carried in `canonical_locale_id`. This
70+
# keeps a string and an equivalent (maximized) `%LanguageTag{}`
71+
# rendering identically and prevents likely-subtags from leaking into
72+
# the output — bare `"en"` is "English", not "English (United States)".
73+
with {:ok, validated} <- Localize.validate_locale(locale) do
74+
validated
75+
|> canonical_display_tag()
76+
|> do_display_name(options)
77+
end
78+
end
6779

68-
def display_name(language_tag, options)
69-
when is_binary(language_tag) or is_atom(language_tag) do
70-
with {:ok, parsed} <- Localize.LanguageTag.parse(to_string(language_tag)),
71-
{:ok, canonical} <- Localize.LanguageTag.canonicalize(parsed) do
72-
display_name(canonical, options)
80+
# Reduce the maximized subtag fields to the canonical-syntax form held
81+
# in `canonical_locale_id` (the caller's explicit request), keeping the
82+
# validated tag's resolved `-u-`/`-t-` extensions and private use.
83+
defp canonical_display_tag(%Localize.LanguageTag{canonical_locale_id: id} = validated)
84+
when is_binary(id) do
85+
case Localize.LanguageTag.parse(id) do
86+
{:ok,
87+
%Localize.LanguageTag{
88+
language: language,
89+
script: script,
90+
territory: territory,
91+
language_variants: variants
92+
}} ->
93+
%{
94+
validated
95+
| language: language,
96+
script: script,
97+
territory: territory,
98+
language_variants: variants
99+
}
100+
101+
_ ->
102+
validated
73103
end
74104
end
75105

76-
def display_name(%Localize.LanguageTag{} = language_tag, options) do
106+
defp canonical_display_tag(%Localize.LanguageTag{} = validated), do: validated
107+
108+
defp do_display_name(%Localize.LanguageTag{} = language_tag, options) do
77109
locale_id = resolve_locale_id(options)
78110
prefer = Keyword.get(options, :prefer, :standard)
79111
prefer = if prefer == :default, do: :standard, else: prefer
@@ -178,7 +210,9 @@ defmodule Localize.Locale.LocaleDisplay do
178210
# ── First Match (locale name specificity cascade) ────────────
179211

180212
# Tries locale name combinations from most specific to least:
181-
# lang-script-territory → lang-territory → lang-script → lang
213+
# lang-script-territory → lang-script → lang-territory → lang
214+
# Per TR35 the two-subtag tie is broken toward the subtag matching
215+
# earlier in the identifier, so lang-script precedes lang-territory.
182216

183217
defp first_match(language_tag, match_fun, :dialect) do
184218
do_first_match(language_tag, match_fun)
@@ -217,8 +251,8 @@ defmodule Localize.Locale.LocaleDisplay do
217251

218252
defp try_matches(language, script, territory, fun) do
219253
locale_name_from(language, script, territory) |> fun.([:language, :script, :territory]) ||
220-
locale_name_from(language, nil, territory) |> fun.([:language, :territory]) ||
221254
locale_name_from(language, script, nil) |> fun.([:language, :script]) ||
255+
locale_name_from(language, nil, territory) |> fun.([:language, :territory]) ||
222256
locale_name_from(language, nil, nil) |> fun.([:language])
223257
end
224258

@@ -227,10 +261,10 @@ defmodule Localize.Locale.LocaleDisplay do
227261

228262
locale_name_from(language, script, territory, variant_str)
229263
|> fun.([:language, :script, :territory, :language_variants]) ||
230-
locale_name_from(language, nil, territory, variant_str)
231-
|> fun.([:language, :territory, :language_variants]) ||
232264
locale_name_from(language, script, nil, variant_str)
233265
|> fun.([:language, :script, :language_variants]) ||
266+
locale_name_from(language, nil, territory, variant_str)
267+
|> fun.([:language, :territory, :language_variants]) ||
234268
locale_name_from(language, nil, nil, variant_str)
235269
|> fun.([:language, :language_variants])
236270
end

lib/localize/validity/u.ex

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,21 @@ defmodule Localize.Validity.U do
9797
|> String.downcase()
9898
end
9999

100+
# An `rg` region override is a 2-letter region suffixed with "zzzz"
101+
# (e.g. `:US` → "uszzzz"). A subdivision override (e.g. `:gbeng` for
102+
# GB-ENG) is emitted as-is — appending the filler would corrupt it into
103+
# an unresolvable value like "gbengzzzz".
100104
defp encode_key("rg", value) when is_atom(value) do
101-
value
102-
|> Atom.to_string()
103-
|> String.downcase()
104-
|> Kernel.<>(@region_subtag_filler)
105+
encoded =
106+
value
107+
|> Atom.to_string()
108+
|> String.downcase()
109+
110+
if String.length(encoded) == 2 do
111+
encoded <> @region_subtag_filler
112+
else
113+
encoded
114+
end
105115
end
106116

107117
defp encode_key("rg", value) do

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Localize.MixProject do
22
use Mix.Project
33

4-
@version "0.41.2"
4+
@version "0.41.3"
55
@cldr_version_path "priv/localize/version"
66
@localize_patch_version_path "priv/localize/localize_patch_version"
77

test/localize/language_tag_test.exs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,9 @@ defmodule Localize.LanguageTagTest do
247247
assert tag.language == :zh
248248
assert tag.script == :Hant
249249
assert tag.territory == :TW
250-
assert tag.canonical_locale_id == "zh-Hant"
250+
# canonical_locale_id preserves the caller's explicit subtags in
251+
# canonical syntax; the fields carry the maximized resolution.
252+
assert tag.canonical_locale_id == "zh-TW"
251253
assert tag.cldr_locale_id != nil
252254
end
253255

@@ -267,10 +269,21 @@ defmodule Localize.LanguageTagTest do
267269
assert tag.language == :en
268270
assert tag.script == :Latn
269271
assert tag.territory == :US
270-
assert tag.canonical_locale_id == "en-u-ca-gregory"
272+
assert tag.canonical_locale_id == "en-US-u-ca-gregory"
271273
assert tag.cldr_locale_id != nil
272274
end
273275

276+
test "accepts a -u-rg- subdivision region override without corrupting it" do
277+
# A subdivision override (gbeng = GB-ENG) must not have the region
278+
# filler "zzzz" appended, which would produce an unresolvable value.
279+
{:ok, tag} = LanguageTag.new("en-u-rg-gbeng")
280+
assert tag.canonical_locale_id == "en-u-rg-gbeng"
281+
282+
# The 2-letter region override form still round-trips with the filler.
283+
{:ok, region} = LanguageTag.new("en-u-rg-uszzzz")
284+
assert region.canonical_locale_id == "en-u-rg-uszzzz"
285+
end
286+
274287
test "preserves requested_locale_id" do
275288
{:ok, tag} = LanguageTag.new("EN-us")
276289
assert tag.requested_locale_id == "EN-us"

test/localize/language_test.exs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,31 @@ defmodule Localize.LanguageTest do
6161
assert {:ok, "Arabic"} == Language.display_name("ar-SA")
6262
end
6363

64+
# Per TR35 the display lookup canonicalizes but does not add likely
65+
# subtags: a bare language keeps its own name, and only an explicitly
66+
# supplied region/script resolves to a region-specific CLDR name.
67+
test "a bare language is not maximized to a region-specific name" do
68+
assert {:ok, "English"} == Language.display_name("en")
69+
assert {:ok, "Spanish"} == Language.display_name("es")
70+
assert {:ok, "Chinese"} == Language.display_name("zh")
71+
assert {:ok, "Portuguese"} == Language.display_name("pt")
72+
73+
assert {:ok, "American English"} == Language.display_name("en-US")
74+
assert {:ok, "Simplified Chinese"} == Language.display_name("zh-Hans")
75+
end
76+
77+
# Per TR35, the candidate cascade tries lang-script before lang-region
78+
# (the two-subtag tie is broken toward the earlier subtag). No CLDR
79+
# language currently has both a lang-script and lang-region name, so the
80+
# tie itself is untriggerable — but the reordering must not shadow a
81+
# region-specific name (`en-GB`) behind the always-present maximized
82+
# script candidate (`en-Latn`), nor break a script-specific name.
83+
test "candidate order does not shadow region- or script-specific names" do
84+
assert {:ok, "British English"} == Language.display_name("en-GB")
85+
assert {:ok, "Traditional Chinese"} == Language.display_name("zh-Hant")
86+
assert {:ok, "English"} == Language.display_name("en-IN")
87+
end
88+
6489
test "falls back to default locale when fallback is true" do
6590
# "ccp" (Chakma) may not exist in all locales but should be in :en
6691
assert {:ok, _name} = Language.display_name("ccp", locale: :de, fallback: true)

test/localize/locale/locale_display_test.exs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,23 @@ defmodule Localize.Locale.LocaleDisplayTest do
9595
assert {:ok, "English"} = Localize.Locale.LocaleDisplay.display_name("en")
9696
end
9797

98+
test "a string and an equivalent (maximized) language tag are equivalent" do
99+
# Both inputs are routed through Localize.validate_locale/1 and the
100+
# display is driven off canonical_locale_id, so no likely subtags leak.
101+
for locale <- ["en", "en-US", "pt", "pt-BR", "zh-Hant"] do
102+
{:ok, tag} = Localize.validate_locale(locale)
103+
104+
assert Localize.Locale.LocaleDisplay.display_name(locale) ==
105+
Localize.Locale.LocaleDisplay.display_name(tag)
106+
end
107+
end
108+
109+
test "a bare language does not gain a likely region" do
110+
assert {:ok, "English"} = Localize.Locale.LocaleDisplay.display_name("en")
111+
{:ok, tag} = Localize.validate_locale("en")
112+
assert {:ok, "English"} = Localize.Locale.LocaleDisplay.display_name(tag)
113+
end
114+
98115
test "language with territory in standard mode" do
99116
assert {:ok, "English (United States)"} =
100117
Localize.Locale.LocaleDisplay.display_name("en-US")
@@ -166,7 +183,7 @@ defmodule Localize.Locale.LocaleDisplayTest do
166183
end
167184

168185
test "raises on invalid input" do
169-
assert_raise Localize.ParseError, fn ->
186+
assert_raise Localize.InvalidLocaleError, fn ->
170187
Localize.Locale.LocaleDisplay.display_name!("xyz-invalid-totally-bad")
171188
end
172189
end

0 commit comments

Comments
 (0)