Skip to content

Commit 17062a8

Browse files
proggeramlugRalph Küpper
andauthored
fix(intl): #5904 — ResolveLocale for the nu numbering-system extension (#5963)
Reconcile the requested locale's `-u-nu-` keyword with an explicit `options.numberingSystem` per ECMA-402 ResolveLocale, and update the resolved locale so `resolvedOptions().locale` / `.numberingSystem` reflect only the *supported* value actually used: * option present + supported → option wins; the `-u-nu-` keyword survives in the locale only when it names the same value * no usable option → fall back to the supported locale extension, else the `latn` default (keyword dropped) A numbering system counts as supported when it is `latn` (default Latin/ASCII digits) or Perry has a transliteration table for it. Base-tag casing is preserved (`en-US` no longer collapses to `en-us`). New `intl/numbering_system.rs` module holds the `nu`-extension BCP-47 tag helpers (split out of `intl.rs` to keep it under the 2,000-line gate). Wired into NumberFormat, DateTimeFormat, and DurationFormat construction. Fixes intl402 resolved-numbering-system-unicode-extensions-and-options.js for NumberFormat, DateTimeFormat, and DurationFormat (3 tests). Zero regressions across all three slices plus Collator. Refs #5904 #5899 #5906 Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 32aa095 commit 17062a8

4 files changed

Lines changed: 216 additions & 56 deletions

File tree

crates/perry-runtime/src/intl.rs

Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ mod list_relative_plural;
3434
mod number_format;
3535
mod number_format_digits;
3636
mod number_format_options;
37+
mod numbering_system;
38+
use numbering_system::{is_well_formed_numbering_system, resolve_numbering_system};
3739
mod segmenter;
3840

3941
pub(crate) use date_collator::{
@@ -441,50 +443,6 @@ fn currency_fraction_digits(code: &str) -> u32 {
441443
}
442444
}
443445

444-
/// A `numberingSystem` value is structurally valid when it is one or more
445-
/// hyphen-separated subtags of 3–8 alphanumerics (the `type` Unicode nonterminal).
446-
fn is_well_formed_numbering_system(value: &str) -> bool {
447-
!value.is_empty()
448-
&& value.split('-').all(|sub| {
449-
(3..=8).contains(&sub.len()) && sub.bytes().all(|b| b.is_ascii_alphanumeric())
450-
})
451-
}
452-
453-
/// Extract the `-u-nu-<value>` numbering system from a (canonicalized) locale
454-
/// string, lower-cased. Returns `None` when no `nu` keyword is present.
455-
fn numbering_system_from_locale(locale: &str) -> Option<String> {
456-
let lower = locale.to_ascii_lowercase();
457-
let subtags: Vec<&str> = lower.split('-').collect();
458-
let u = subtags.iter().position(|s| *s == "u")?;
459-
let mut i = u + 1;
460-
while i < subtags.len() {
461-
let key = subtags[i];
462-
// A keyword key is exactly two chars; everything up to the next key is its value.
463-
if key.len() == 2 {
464-
if key == "nu" {
465-
let mut value = String::new();
466-
let mut j = i + 1;
467-
while j < subtags.len() && subtags[j].len() != 2 {
468-
if !value.is_empty() {
469-
value.push('-');
470-
}
471-
value.push_str(subtags[j]);
472-
j += 1;
473-
}
474-
return (!value.is_empty()).then_some(value);
475-
}
476-
i += 1;
477-
while i < subtags.len() && subtags[i].len() != 2 {
478-
i += 1;
479-
}
480-
} else {
481-
// Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`.
482-
break;
483-
}
484-
}
485-
None
486-
}
487-
488446
#[cold]
489447
fn throw_type_error(message: &str) -> ! {
490448
let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32);
@@ -1125,15 +1083,23 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
11251083
)),
11261084
}
11271085
}
1128-
// `numberingSystem` must be a well-formed `type` nonterminal.
1129-
if let Some(ns) = get_locale_extension_option(options, "numberingSystem") {
1086+
// `numberingSystem` must be a well-formed `type` nonterminal. Read
1087+
// it here (preserving the GetOption order options-order.js asserts),
1088+
// then run ResolveLocale for `nu` — reconciling the option with the
1089+
// locale's `-u-nu-` keyword so `resolvedOptions().locale` /
1090+
// `.numberingSystem` reflect only the supported value actually used.
1091+
let dtf_opt_ns = get_locale_extension_option(options, "numberingSystem").map(|ns| {
11301092
if !is_well_formed_numbering_system(&ns) {
11311093
throw_range_error(&format!(
11321094
"Value {ns} out of range for Intl options property numberingSystem"
11331095
));
11341096
}
1135-
set_internal_field(obj, KEY_NUMBERING_SYSTEM, string_value(&ns));
1136-
}
1097+
ns.to_ascii_lowercase()
1098+
});
1099+
let (dtf_locale, dtf_numbering) =
1100+
resolve_numbering_system(&locale, dtf_opt_ns.as_deref());
1101+
set_internal_field(obj, KEY_LOCALE, string_value(&dtf_locale));
1102+
set_internal_field(obj, KEY_NUMBERING_SYSTEM, string_value(&dtf_numbering));
11371103
// hour12 (boolean) then hourCycle (enum) — both only surface in
11381104
// `resolvedOptions` when the resolved pattern has an hour field.
11391105
if let Some(h12) = get_bool_option(options, "hour12") {

crates/perry-runtime/src/intl/duration_format.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,17 +236,23 @@ pub(super) fn configure(obj: *mut ObjectHeader, options: f64) {
236236
"best fit",
237237
);
238238

239-
let numbering = match df_get_option_string(options, "numberingSystem") {
239+
let opt_ns = match df_get_option_string(options, "numberingSystem") {
240240
Some(ns) => {
241241
if !valid_numbering_system(&ns) {
242242
throw_range_error(&format!(
243243
"Value {ns} out of range for Intl.DurationFormat options property numberingSystem"
244244
));
245245
}
246-
ns
246+
Some(ns.to_ascii_lowercase())
247247
}
248-
None => "latn".to_string(),
248+
None => None,
249249
};
250+
// ResolveLocale for `nu`: reconcile the option with the requested locale's
251+
// `-u-nu-` keyword (stored in KEY_LOCALE at construction) and update both the
252+
// resolved locale and numbering system.
253+
let locale = get_string_field(obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string());
254+
let (resolved_locale, numbering) = super::resolve_numbering_system(&locale, opt_ns.as_deref());
255+
set_internal_field(obj, KEY_LOCALE, string_value(&resolved_locale));
250256
set_internal_field(obj, KEY_DF_NUMBERING, string_value(&numbering));
251257

252258
let base_style = df_enum_option(

crates/perry-runtime/src/intl/number_format_options.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,24 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti
3333
"best fit",
3434
);
3535

36-
// numberingSystem: option (validated, lower-cased) overrides the locale
37-
// `-u-nu-` keyword; default "latn".
38-
let numbering = match get_option_string(options, "numberingSystem") {
36+
// numberingSystem: validate the option (well-formed `type` nonterminal),
37+
// then run ResolveLocale for the `nu` key — reconciling the option with the
38+
// requested locale's `-u-nu-` keyword and updating the resolved locale so
39+
// `resolvedOptions().locale` reflects only the supported value actually used.
40+
let opt_ns = match get_option_string(options, "numberingSystem") {
3941
Some(value) => {
4042
let lower = value.to_ascii_lowercase();
4143
if !is_well_formed_numbering_system(&lower) {
4244
throw_range_error(&format!(
4345
"Value {value} out of range for Intl.NumberFormat options property numberingSystem"
4446
));
4547
}
46-
lower
48+
Some(lower)
4749
}
48-
None => numbering_system_from_locale(locale).unwrap_or_else(|| "latn".to_string()),
50+
None => None,
4951
};
52+
let (resolved_locale, numbering) = resolve_numbering_system(locale, opt_ns.as_deref());
53+
set_internal_field(obj, KEY_LOCALE, string_value(&resolved_locale));
5054
set_internal_field(obj, KEY_NF_NUMBERING, string_value(&numbering));
5155

5256
// SetNumberFormatUnitOptions.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! `-u-nu-` (numbering system) Unicode-extension resolution for the Intl
2+
//! service constructors. Splitting these BCP-47 tag helpers out of `intl.rs`
3+
//! keeps that namespace module under the repository's 2,000-line gate.
4+
5+
/// A `numberingSystem` value is structurally valid when it is one or more
6+
/// hyphen-separated subtags of 3–8 alphanumerics (the `type` Unicode nonterminal).
7+
pub(super) fn is_well_formed_numbering_system(value: &str) -> bool {
8+
!value.is_empty()
9+
&& value.split('-').all(|sub| {
10+
(3..=8).contains(&sub.len()) && sub.bytes().all(|b| b.is_ascii_alphanumeric())
11+
})
12+
}
13+
14+
/// A numbering system is *supported* when it is the default `latn` (Latin/ASCII
15+
/// digits, which need no transliteration table) or Perry has a digit table for
16+
/// it. This is the set `resolvedOptions().numberingSystem` may report; other
17+
/// (e.g. algorithmic) systems are treated as unsupported and fall back to `latn`.
18+
pub(super) fn is_supported_numbering_system(name: &str) -> bool {
19+
name == "latn" || super::number_format_digits::numbering_system_digits(name).is_some()
20+
}
21+
22+
/// ResolveLocale for the `nu` (numbering system) Unicode extension key
23+
/// (ECMA-402): reconciles the requested locale's `-u-nu-` keyword with an
24+
/// explicit `options.numberingSystem`, and returns the resolved
25+
/// `(locale, numberingSystem)` pair. `opt_ns` is the already-validated,
26+
/// lower-cased option value (or `None`). The resolved locale keeps `-u-nu-X`
27+
/// only when `X` is the *supported* value actually used AND it originated from
28+
/// the locale extension (i.e. an option that differs from a supported extension
29+
/// drops the keyword). See NumberFormat resolved-numbering-system test262.
30+
pub(super) fn resolve_numbering_system(locale: &str, opt_ns: Option<&str>) -> (String, String) {
31+
let ext_ns =
32+
numbering_system_from_locale(locale).filter(|ns| is_supported_numbering_system(ns));
33+
let opt_supported = opt_ns.filter(|ns| is_supported_numbering_system(ns));
34+
35+
let (resolved_ns, keep_ext) = match (opt_supported, &ext_ns) {
36+
// Option present and supported: it wins; the locale keyword survives only
37+
// when it names the same value.
38+
(Some(opt), ext) => (opt.to_string(), ext.as_deref() == Some(opt)),
39+
// No usable option: fall back to the supported extension, else default.
40+
(None, Some(ext)) => (ext.clone(), true),
41+
(None, None) => ("latn".to_string(), false),
42+
};
43+
44+
let resolved_locale = if keep_ext {
45+
with_numbering_system_keyword(locale, &resolved_ns)
46+
} else {
47+
strip_numbering_system_keyword(locale)
48+
};
49+
(resolved_locale, resolved_ns)
50+
}
51+
52+
/// Extract the `-u-nu-<value>` numbering system from a (canonicalized) locale
53+
/// string, lower-cased. Returns `None` when no `nu` keyword is present.
54+
pub(super) fn numbering_system_from_locale(locale: &str) -> Option<String> {
55+
let lower = locale.to_ascii_lowercase();
56+
let subtags: Vec<&str> = lower.split('-').collect();
57+
let u = subtags.iter().position(|s| *s == "u")?;
58+
let mut i = u + 1;
59+
while i < subtags.len() {
60+
let key = subtags[i];
61+
// A keyword key is exactly two chars; everything up to the next key is its value.
62+
if key.len() == 2 {
63+
if key == "nu" {
64+
let mut value = String::new();
65+
let mut j = i + 1;
66+
while j < subtags.len() && subtags[j].len() != 2 {
67+
if !value.is_empty() {
68+
value.push('-');
69+
}
70+
value.push_str(subtags[j]);
71+
j += 1;
72+
}
73+
return (!value.is_empty()).then_some(value);
74+
}
75+
i += 1;
76+
while i < subtags.len() && subtags[i].len() != 2 {
77+
i += 1;
78+
}
79+
} else {
80+
// Hit another singleton extension (e.g. `-t-`); `nu` lives only under `u`.
81+
break;
82+
}
83+
}
84+
None
85+
}
86+
87+
/// Split a locale tag into `(base, u_keywords, tail_after_u)`, where
88+
/// `u_keywords` is the ordered list of `(key, value)` pairs inside the `-u-`
89+
/// extension and `tail` is everything from the next singleton onward (e.g. a
90+
/// `-t-`/`-x-` sequence). Returns `None` when the tag has no `-u-` extension.
91+
/// The `base` keeps its original canonical casing (`en-US`); the extension /
92+
/// tail regions are lower-cased per UTS #35.
93+
fn split_u_extension(locale: &str) -> Option<(String, Vec<(String, Vec<String>)>, String)> {
94+
// Preserve the base region's casing (`en-US` must not become `en-us`); only
95+
// the extension region is canonically lower-cased.
96+
let subtags: Vec<&str> = locale.split('-').collect();
97+
let u = subtags.iter().position(|s| s.eq_ignore_ascii_case("u"))?;
98+
let base = subtags[..u].join("-");
99+
let lower: Vec<String> = subtags.iter().map(|s| s.to_ascii_lowercase()).collect();
100+
101+
let mut keywords: Vec<(String, Vec<String>)> = Vec::new();
102+
let mut i = u + 1;
103+
let mut tail_start = subtags.len();
104+
while i < subtags.len() {
105+
let sub = lower[i].as_str();
106+
if sub.len() == 1 {
107+
// Next singleton (`t`/`x`/…) ends the `u` extension.
108+
tail_start = i;
109+
break;
110+
}
111+
// A keyword key is exactly two chars; the value runs until the next key.
112+
if sub.len() == 2 {
113+
let key = sub.to_string();
114+
let mut value = Vec::new();
115+
let mut j = i + 1;
116+
while j < subtags.len() && lower[j].len() != 2 && lower[j].len() != 1 {
117+
value.push(lower[j].clone());
118+
j += 1;
119+
}
120+
keywords.push((key, value));
121+
i = j;
122+
} else {
123+
// An `-u-` attribute (3+ chars with no preceding key). Keep it as a
124+
// value-less pseudo-keyword so round-tripping doesn't drop it.
125+
keywords.push((sub.to_string(), Vec::new()));
126+
i += 1;
127+
}
128+
}
129+
let tail = if tail_start < subtags.len() {
130+
lower[tail_start..].join("-")
131+
} else {
132+
String::new()
133+
};
134+
Some((base, keywords, tail))
135+
}
136+
137+
/// Reassemble a locale from a `split_u_extension` decomposition, dropping the
138+
/// `-u-` extension entirely when no keywords remain.
139+
fn rebuild_locale(base: &str, keywords: &[(String, Vec<String>)], tail: &str) -> String {
140+
let mut out = base.to_string();
141+
if !keywords.is_empty() {
142+
out.push_str("-u");
143+
for (key, value) in keywords {
144+
out.push('-');
145+
out.push_str(key);
146+
for v in value {
147+
out.push('-');
148+
out.push_str(v);
149+
}
150+
}
151+
}
152+
if !tail.is_empty() {
153+
out.push('-');
154+
out.push_str(tail);
155+
}
156+
out
157+
}
158+
159+
/// Remove the `-u-nu-<value>` keyword from a locale tag (dropping the whole
160+
/// `-u-` extension if it becomes empty). A tag with no `nu` keyword is returned
161+
/// unchanged.
162+
fn strip_numbering_system_keyword(locale: &str) -> String {
163+
let Some((base, mut keywords, tail)) = split_u_extension(locale) else {
164+
return locale.to_string();
165+
};
166+
keywords.retain(|(key, _)| key != "nu");
167+
rebuild_locale(&base, &keywords, &tail)
168+
}
169+
170+
/// Ensure the locale tag carries `-u-nu-<ns>` (adding a `-u-` extension if
171+
/// absent, or replacing an existing `nu` value).
172+
fn with_numbering_system_keyword(locale: &str, ns: &str) -> String {
173+
let (base, mut keywords, tail) = match split_u_extension(locale) {
174+
Some(parts) => parts,
175+
None => (locale.to_string(), Vec::new(), String::new()),
176+
};
177+
let value = vec![ns.to_string()];
178+
if let Some(entry) = keywords.iter_mut().find(|(key, _)| key == "nu") {
179+
entry.1 = value;
180+
} else {
181+
keywords.push(("nu".to_string(), value));
182+
}
183+
rebuild_locale(&base, &keywords, &tail)
184+
}

0 commit comments

Comments
 (0)