diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index fc0d4bf1c..a8fc1e2af 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -260,11 +260,19 @@ fn get_option_value(options: f64, key: &str) -> f64 { get_field(obj, key) } -fn get_option_string(options: f64, key: &str) -> Option { - let value = get_option_value(options, key); +/// Coerce an already-fetched option value to its GetOption string form. ECMA-402 +/// GetOption treats ONLY `undefined` as "absent → fallback"; every other value — +/// `null` included — is coerced with ToString and then checked against the +/// allow-list, so `{ localeMatcher: null }` must surface as the string "null" +/// (which no enum accepts) and raise a RangeError, not be silently ignored. +/// Kept separate from the property read so callers that must observe the option +/// getter exactly once (the GetOption call-order tests) can reuse the value. +fn coerce_option_string(value: f64) -> Option { let js = JSValue::from_bits(value.to_bits()); - if js.is_undefined() || js.is_null() { + if js.is_undefined() { None + } else if js.is_null() { + Some("null".to_string()) } else if js.is_any_string() { string_from_string_value(value) } else { @@ -272,6 +280,29 @@ fn get_option_string(options: f64, key: &str) -> Option { } } +fn get_option_string(options: f64, key: &str) -> Option { + coerce_option_string(get_option_value(options, key)) +} + +/// As `get_option_string`, but for the Unicode locale-extension keys (`calendar`, +/// `numberingSystem`) whose value is validated for *well-formedness* rather than +/// against a closed enum. ECMA-402 coerces `null` to the string `"null"` — a +/// well-formed `type` subtag that names no supported calendar / numbering system, +/// so ResolveLocale drops it and `resolvedOptions` reports the locale default +/// (`gregory` / `latn`). Perry models no per-locale extension negotiation and +/// otherwise echoes the requested value verbatim, so it mirrors that observable +/// outcome by treating `null` as "absent" (leaving the field at its default) +/// rather than reporting a literal `"null"`. A non-null unsupported value is +/// still echoed, matching Perry's existing behaviour. The option getter is read +/// exactly once so the GetOption call-order is preserved. +fn get_locale_extension_option(options: f64, key: &str) -> Option { + let value = get_option_value(options, key); + if JSValue::from_bits(value.to_bits()).is_null() { + return None; + } + coerce_option_string(value) +} + fn get_option_number(options: f64, key: &str) -> Option { let value = get_option_value(options, key); let js = JSValue::from_bits(value.to_bits()); @@ -838,7 +869,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option ); // `calendar` must match the Unicode locale `type` nonterminal; store // the canonicalized ID so `resolvedOptions().calendar` reflects it. - if let Some(calendar) = get_option_string(options, "calendar") { + if let Some(calendar) = get_locale_extension_option(options, "calendar") { match canonicalize_calendar_id(&calendar) { Some(canonical) => { set_internal_field(obj, KEY_CALENDAR, string_value(&canonical)) @@ -849,7 +880,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option } } // `numberingSystem` must be a well-formed `type` nonterminal. - if let Some(ns) = get_option_string(options, "numberingSystem") { + if let Some(ns) = get_locale_extension_option(options, "numberingSystem") { if !is_well_formed_numbering_system(&ns) { throw_range_error(&format!( "Value {ns} out of range for Intl options property numberingSystem" diff --git a/test-files/test_gap_intl_datetimeformat_null_option_5582.ts b/test-files/test_gap_intl_datetimeformat_null_option_5582.ts new file mode 100644 index 000000000..f0963cc6c --- /dev/null +++ b/test-files/test_gap_intl_datetimeformat_null_option_5582.ts @@ -0,0 +1,32 @@ +// #5582 — Intl.DateTimeFormat option validation for a `null` option value. +// +// GetOption (ECMA-402) treats ONLY `undefined` as "absent → fallback"; every +// other value, `null` included, is coerced with ToString. So: +// * an enum option (localeMatcher, weekday, hourCycle, dateStyle, …) sees the +// string "null", which no allow-list accepts → RangeError; and +// * a Unicode locale-extension key (calendar, numberingSystem) sees "null", +// which is a well-formed but unsupported `type` subtag, so ResolveLocale +// drops it and resolvedOptions reports the locale default (gregory / latn). +// Both branches must match `node --experimental-strip-types` byte-for-byte. +function probe(opt: string): string { + try { + const options: Record = {}; + options[opt] = null; + new Intl.DateTimeFormat(undefined, options as Intl.DateTimeFormatOptions); + return "accepted"; + } catch (e) { + return (e as Error).constructor.name; + } +} + +for (const opt of ["localeMatcher", "formatMatcher", "weekday", "hourCycle", "dateStyle"]) { + console.log(opt, probe(opt)); +} + +// Locale-extension keys fall back to the locale default rather than throwing. +const ro = new Intl.DateTimeFormat("en", { + calendar: null as unknown as string, + numberingSystem: null as unknown as string, +}).resolvedOptions(); +console.log("calendar", ro.calendar); +console.log("numberingSystem", ro.numberingSystem);