Skip to content

Commit 5b24430

Browse files
author
Ralph Kuepper
committed
fix(intl): resolve DateTimeFormat locale options (#5899)
1 parent e2eee40 commit 5b24430

4 files changed

Lines changed: 263 additions & 29 deletions

File tree

crates/perry-runtime/src/intl.rs

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ mod locale;
3333
mod locales;
3434
use locales::{get_canonical_locales_thunk, supported_values_of_thunk};
3535
mod date_collator;
36+
mod date_time_locale;
37+
use date_time_locale::resolve_date_time_locale;
3638
mod date_names;
3739
#[cfg(feature = "intl-datetime")]
3840
pub(crate) mod icu_dtf;
@@ -1089,20 +1091,16 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
10891091
&["lookup", "best fit"],
10901092
"best fit",
10911093
);
1092-
// `calendar` must match the Unicode locale `type` nonterminal; store
1093-
// the canonicalized ID so `resolvedOptions().calendar` reflects it.
1094-
if let Some(calendar) = get_locale_extension_option(current_options(), "calendar") {
1095-
match canonicalize_calendar_id(&calendar) {
1096-
Some(canonical) => set_internal_field_from_raw_handle(
1097-
&obj_handle,
1098-
KEY_CALENDAR,
1099-
string_value(&canonical),
1100-
),
1101-
None => throw_range_error(&format!(
1102-
"Value {calendar} out of range for Intl options property calendar"
1103-
)),
1104-
}
1105-
}
1094+
// `calendar` must match the Unicode locale `type` nonterminal.
1095+
// Unsupported well-formed values fall through ResolveLocale.
1096+
let calendar_option =
1097+
get_locale_extension_option(current_options(), "calendar").map(|calendar| {
1098+
canonicalize_calendar_id(&calendar).unwrap_or_else(|| {
1099+
throw_range_error(&format!(
1100+
"Value {calendar} out of range for Intl options property calendar"
1101+
))
1102+
})
1103+
});
11061104
// `numberingSystem` must be a well-formed `type` nonterminal. Read
11071105
// it here (preserving the GetOption order options-order.js asserts),
11081106
// then run ResolveLocale for `nu` — reconciling the option with the
@@ -1117,25 +1115,43 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
11171115
}
11181116
ns.to_ascii_lowercase()
11191117
});
1120-
let (dtf_locale, dtf_numbering) =
1121-
resolve_numbering_system(&locale, dtf_opt_ns.as_deref());
1122-
set_internal_field_from_raw_handle(&obj_handle, KEY_LOCALE, string_value(&dtf_locale));
1123-
set_internal_field_from_raw_handle(
1124-
&obj_handle,
1125-
KEY_NUMBERING_SYSTEM,
1126-
string_value(&dtf_numbering),
1127-
);
11281118
// hour12 (boolean) then hourCycle (enum) — both only surface in
11291119
// `resolvedOptions` when the resolved pattern has an hour field.
1130-
if let Some(h12) = get_bool_option(current_options(), "hour12") {
1131-
set_internal_field_from_raw_handle(&obj_handle, KEY_HOUR12, bool_value(h12));
1132-
}
1133-
if let Some(hc) = get_option_string(current_options(), "hourCycle") {
1120+
let hour12 = get_bool_option(current_options(), "hour12");
1121+
let hour_cycle_option = get_option_string(current_options(), "hourCycle");
1122+
if let Some(ref hc) = hour_cycle_option {
11341123
if !["h11", "h12", "h23", "h24"].contains(&hc.as_str()) {
11351124
throw_range_error(&format!(
11361125
"Value {hc} out of range for Intl options property hourCycle"
11371126
));
11381127
}
1128+
}
1129+
let resolved = resolve_date_time_locale(
1130+
&locale,
1131+
calendar_option.as_deref(),
1132+
dtf_opt_ns.as_deref(),
1133+
hour12,
1134+
hour_cycle_option.as_deref(),
1135+
);
1136+
set_internal_field_from_raw_handle(
1137+
&obj_handle,
1138+
KEY_LOCALE,
1139+
string_value(&resolved.locale),
1140+
);
1141+
set_internal_field_from_raw_handle(
1142+
&obj_handle,
1143+
KEY_CALENDAR,
1144+
string_value(&resolved.calendar),
1145+
);
1146+
set_internal_field_from_raw_handle(
1147+
&obj_handle,
1148+
KEY_NUMBERING_SYSTEM,
1149+
string_value(&resolved.numbering_system),
1150+
);
1151+
if let Some(h12) = hour12 {
1152+
set_internal_field_from_raw_handle(&obj_handle, KEY_HOUR12, bool_value(h12));
1153+
}
1154+
if let Some(hc) = resolved.hour_cycle {
11391155
set_internal_field_from_raw_handle(&obj_handle, KEY_HOUR_CYCLE, string_value(&hc));
11401156
}
11411157
// ECMA-402 DefaultTimeZone(): when no `timeZone` option is given, use
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! ResolveLocale support for `Intl.DateTimeFormat`'s `ca`, `hc`, and `nu`
2+
//! relevant extension keys.
3+
4+
use super::*;
5+
6+
/// The calendars Perry exposes through `Intl.supportedValuesOf("calendar")`.
7+
/// CreateDateTimeFormat may only retain one of these values; well-formed future
8+
/// or implementation-specific identifiers fall back through ResolveLocale.
9+
const SUPPORTED_CALENDARS: &[&str] = &[
10+
"buddhist",
11+
"chinese",
12+
"coptic",
13+
"dangi",
14+
"ethioaa",
15+
"ethiopic",
16+
"gregory",
17+
"hebrew",
18+
"indian",
19+
"islamic",
20+
"islamic-civil",
21+
"islamic-rgsa",
22+
"islamic-tbla",
23+
"islamic-umalqura",
24+
"iso8601",
25+
"japanese",
26+
"persian",
27+
"roc",
28+
];
29+
30+
pub(super) struct DateTimeLocaleResolution {
31+
pub(super) locale: String,
32+
pub(super) calendar: String,
33+
pub(super) numbering_system: String,
34+
/// Effective `hc` override from the extension or options. Locale defaults
35+
/// remain absent so ICU can select its own CLDR preference.
36+
pub(super) hour_cycle: Option<String>,
37+
}
38+
39+
fn base_locale(locale: &str) -> String {
40+
locale
41+
.split('-')
42+
.take_while(|part| part.len() != 1)
43+
.collect::<Vec<_>>()
44+
.join("-")
45+
}
46+
47+
fn supported_calendar(value: &str) -> Option<String> {
48+
let canonical = canonicalize_calendar_id(value)?;
49+
SUPPORTED_CALENDARS
50+
.contains(&canonical.as_str())
51+
.then_some(canonical)
52+
}
53+
54+
fn supported_hour_cycle(value: &str) -> Option<String> {
55+
["h11", "h12", "h23", "h24"]
56+
.contains(&value)
57+
.then(|| value.to_string())
58+
}
59+
60+
fn push_keyword(locale: &mut String, started: &mut bool, key: &str, value: &str) {
61+
if !*started {
62+
locale.push_str("-u");
63+
*started = true;
64+
}
65+
locale.push('-');
66+
locale.push_str(key);
67+
locale.push('-');
68+
locale.push_str(value);
69+
}
70+
71+
/// Apply ResolveLocale to DateTimeFormat's relevant extension keys. Only a
72+
/// supported `ca`, `hc`, or `nu` keyword may survive in the resolved locale;
73+
/// unrelated keys and unsupported values are removed. A supported explicit
74+
/// option wins, while an unsupported option leaves a supported extension value
75+
/// in place. `hour12` suppresses both the `hourCycle` option and the `hc`
76+
/// extension, as required by CreateDateTimeFormat.
77+
pub(super) fn resolve_date_time_locale(
78+
requested: &str,
79+
calendar_option: Option<&str>,
80+
numbering_option: Option<&str>,
81+
hour12: Option<bool>,
82+
hour_cycle_option: Option<&str>,
83+
) -> DateTimeLocaleResolution {
84+
let ext_calendar = unicode_extension_keyword(requested, "ca")
85+
.as_deref()
86+
.and_then(supported_calendar);
87+
let opt_calendar = calendar_option.and_then(supported_calendar);
88+
let calendar = opt_calendar
89+
.or_else(|| ext_calendar.clone())
90+
.unwrap_or_else(|| "gregory".to_string());
91+
92+
let ext_numbering = unicode_extension_keyword(requested, "nu")
93+
.map(|value| value.to_ascii_lowercase())
94+
.filter(|value| numbering_system::is_supported_numbering_system(value));
95+
let opt_numbering = numbering_option
96+
.map(|value| value.to_ascii_lowercase())
97+
.filter(|value| numbering_system::is_supported_numbering_system(value));
98+
let numbering_system = opt_numbering
99+
.or_else(|| ext_numbering.clone())
100+
.unwrap_or_else(|| "latn".to_string());
101+
102+
let ext_hour_cycle = unicode_extension_keyword(requested, "hc")
103+
.as_deref()
104+
.and_then(supported_hour_cycle);
105+
let hour_cycle = if hour12.is_some() {
106+
None
107+
} else {
108+
hour_cycle_option
109+
.and_then(supported_hour_cycle)
110+
.or_else(|| ext_hour_cycle.clone())
111+
};
112+
113+
let mut locale = base_locale(requested);
114+
let mut started = false;
115+
if ext_calendar.as_deref() == Some(calendar.as_str()) {
116+
push_keyword(&mut locale, &mut started, "ca", &calendar);
117+
}
118+
if hour12.is_none() && ext_hour_cycle.as_deref() == hour_cycle.as_deref() {
119+
if let Some(ref hc) = hour_cycle {
120+
push_keyword(&mut locale, &mut started, "hc", hc);
121+
}
122+
}
123+
if ext_numbering.as_deref() == Some(numbering_system.as_str()) {
124+
push_keyword(&mut locale, &mut started, "nu", &numbering_system);
125+
}
126+
127+
DateTimeLocaleResolution {
128+
locale,
129+
calendar,
130+
numbering_system,
131+
hour_cycle,
132+
}
133+
}
134+
135+
#[cfg(test)]
136+
mod tests {
137+
use super::*;
138+
139+
#[test]
140+
fn resolves_supported_and_unsupported_calendar_values() {
141+
let kept = resolve_date_time_locale("en-u-ca-iso8601", Some("invalid"), None, None, None);
142+
assert_eq!(kept.locale, "en-u-ca-iso8601");
143+
assert_eq!(kept.calendar, "iso8601");
144+
145+
let replaced =
146+
resolve_date_time_locale("en-u-ca-gregory", Some("iso8601"), None, None, None);
147+
assert_eq!(replaced.locale, "en");
148+
assert_eq!(replaced.calendar, "iso8601");
149+
150+
let future = resolve_date_time_locale("en-u-ca-bangla", Some("vikram"), None, None, None);
151+
assert_eq!(future.locale, "en");
152+
assert_eq!(future.calendar, "gregory");
153+
154+
let alias = resolve_date_time_locale("en", Some("ethiopic-amete-alem"), None, None, None);
155+
assert_eq!(alias.calendar, "ethioaa");
156+
157+
let existing = resolve_date_time_locale("en", Some("islamic"), None, None, None);
158+
assert_eq!(existing.calendar, "islamic");
159+
}
160+
161+
#[test]
162+
fn removes_irrelevant_extensions_and_resolves_hc_and_nu() {
163+
let irrelevant =
164+
resolve_date_time_locale("ja-JP-u-cu-usd-tz-usnyc", None, None, None, None);
165+
assert_eq!(irrelevant.locale, "ja-JP");
166+
167+
let overridden =
168+
resolve_date_time_locale("en-u-hc-h23-nu-arab", None, None, None, Some("h11"));
169+
assert_eq!(overridden.locale, "en-u-nu-arab");
170+
assert_eq!(overridden.hour_cycle.as_deref(), Some("h11"));
171+
assert_eq!(overridden.numbering_system, "arab");
172+
173+
let hour12 = resolve_date_time_locale("en-u-hc-h11", None, None, Some(false), Some("h23"));
174+
assert_eq!(hour12.locale, "en");
175+
assert_eq!(hour12.hour_cycle, None);
176+
}
177+
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ pub(crate) fn canonicalize_calendar_id(raw: &str) -> Option<String> {
2424
}
2525
}
2626
let lower = raw.to_ascii_lowercase();
27-
// BCP-47 `-u-ca-` type aliases (TR35): a handful of legacy IDs canonicalize
28-
// to their preferred form. Everything else passes through lowercased.
27+
// BCP-47 `-u-ca-` type aliases (TR35): deprecated IDs canonicalize to
28+
// their preferred form. Everything else passes through lowercased.
2929
let canonical = match lower.as_str() {
3030
"islamicc" => "islamic-civil",
31-
"ethioaa" => "ethiopic-amete-alem",
31+
"ethiopic-amete-alem" => "ethioaa",
3232
other => other,
3333
};
3434
Some(canonical.to_string())
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// #5899 — CreateDateTimeFormat ResolveLocale for the `ca`, `hc`, and `nu`
2+
// relevant extension keys. Unsupported values fall back, supported extensions
3+
// survive only when they are actually selected, explicit options override the
4+
// extension, and unrelated Unicode keys never leak into the resolved locale.
5+
6+
function show(
7+
label: string,
8+
locale: string,
9+
options: Intl.DateTimeFormatOptions = {},
10+
): void {
11+
const resolved = new Intl.DateTimeFormat(locale, options).resolvedOptions();
12+
console.log(
13+
label,
14+
resolved.locale,
15+
resolved.calendar,
16+
resolved.numberingSystem,
17+
resolved.hourCycle ?? "-",
18+
);
19+
}
20+
21+
show("future option", "en", { calendar: "bangla" });
22+
show("future extension", "en-u-ca-vikram");
23+
show("calendar alias", "en", { calendar: "ethiopic-amete-alem" });
24+
show("keep calendar", "en-u-ca-iso8601", { calendar: "invalid" });
25+
show("drop calendars", "en-u-ca-invalid", { calendar: "invalid2" });
26+
show("replace calendar", "en-u-ca-gregory", { calendar: "iso8601" });
27+
show("same calendar", "en-u-ca-iso8601", { calendar: "iso8601" });
28+
show("null calendar", "en-u-ca-iso8601", {
29+
calendar: null as unknown as string,
30+
});
31+
32+
show("irrelevant", "ja-JP-u-cu-usd-tz-usnyc");
33+
show("numbering", "en-u-nu-arab");
34+
show("replace hc", "en-u-hc-h23", { hour: "numeric", hourCycle: "h11" });
35+
show("same hc", "en-u-hc-h23", { hour: "numeric", hourCycle: "h23" });
36+
show("hour12 wins", "en-u-hc-h11", {
37+
hour: "numeric",
38+
hour12: false,
39+
hourCycle: "h11",
40+
});
41+
show("extension hc", "en-u-hc-h11", { hour: "numeric" });

0 commit comments

Comments
 (0)