From fceb44a4e37de47b171fe2b919a45e4fef706734 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Sun, 15 Mar 2026 16:20:04 +0530 Subject: [PATCH 1/3] feat(intl): implement Temporal.PlainYearMonth.prototype.toLocaleString --- core/engine/src/builtins/date/utils.rs | 8 ++++ .../builtins/temporal/plain_year_month/mod.rs | 38 +++++++++++++++-- .../temporal/plain_year_month/tests.rs | 42 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 core/engine/src/builtins/temporal/plain_year_month/tests.rs diff --git a/core/engine/src/builtins/date/utils.rs b/core/engine/src/builtins/date/utils.rs index 964c63e541f..400ff1065c1 100644 --- a/core/engine/src/builtins/date/utils.rs +++ b/core/engine/src/builtins/date/utils.rs @@ -442,6 +442,14 @@ pub(super) fn make_date(day: f64, time: f64) -> f64 { tv } +#[cfg(feature = "intl")] +pub(crate) fn timestamp_for_first_of_month_utc(year: i32, month: u8) -> f64 { + debug_assert!((1..=12).contains(&month)); + let day = make_day(f64::from(year), f64::from(month - 1), 1.0); + let time = make_time(0.0, 0.0, 0.0, 0.0); + time_clip(make_date(day, time)) +} + /// Abstract operation `MakeFullYear ( year )` /// /// More info: diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index 1c328da5997..336245d8c4e 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -33,6 +33,10 @@ use super::{ to_temporal_duration, }; +#[cfg(feature = "temporal")] +#[cfg(test)] +mod tests; + /// The `Temporal.PlainYearMonth` built-in implementation /// /// More information: @@ -755,12 +759,15 @@ impl PlainYearMonth { /// /// [spec]: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainYearMonth/toLocaleString + #[allow( + unused_variables, + reason = "`args` and `context` are used when the `intl` feature is enabled" + )] pub(crate) fn to_locale_string( this: &JsValue, - _: &[JsValue], - _: &mut Context, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - // TODO: Update for ECMA-402 compliance let object = this.as_object(); let year_month = object .as_ref() @@ -769,7 +776,30 @@ impl PlainYearMonth { JsNativeError::typ().with_message("this value must be a PlainYearMonth object.") })?; - Ok(JsString::from(year_month.inner.to_string()).into()) + #[cfg(feature = "intl")] + { + use crate::builtins::{ + date::utils::timestamp_for_first_of_month_utc, + intl::date_time_format::{FormatDefaults, FormatType, format_date_time_locale}, + }; + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + let timestamp = + timestamp_for_first_of_month_utc(year_month.inner.year(), year_month.inner.month()); + format_date_time_locale( + locales, + options, + FormatType::Date, + FormatDefaults::Date, + timestamp, + context, + ) + } + + #[cfg(not(feature = "intl"))] + { + Ok(JsString::from(year_month.inner.to_string()).into()) + } } /// 9.3.21 `Temporal.PlainYearMonth.prototype.toJSON ( )` diff --git a/core/engine/src/builtins/temporal/plain_year_month/tests.rs b/core/engine/src/builtins/temporal/plain_year_month/tests.rs new file mode 100644 index 00000000000..9d87b328a50 --- /dev/null +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -0,0 +1,42 @@ +use crate::{JsNativeErrorKind, TestAction, run_test_actions}; + +#[test] +fn to_locale_string_returns_string() { + run_test_actions([ + TestAction::assert( + "typeof Temporal.PlainYearMonth.from('2024-03').toLocaleString() === 'string'", + ), + TestAction::assert("Temporal.PlainYearMonth.from('2024-03').toLocaleString().length > 0"), + ]); +} + +#[test] +fn to_locale_string_invalid_receiver_throws() { + run_test_actions([TestAction::assert_native_error( + "Temporal.PlainYearMonth.prototype.toLocaleString.call({})", + JsNativeErrorKind::Type, + "this value must be a PlainYearMonth object.", + )]); +} + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_different_locales_produce_different_output() { + run_test_actions([TestAction::assert( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US') !== \ + Temporal.PlainYearMonth.from('2024-03').toLocaleString('de-DE')", + )]); +} + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_options_affect_output() { + run_test_actions([ + TestAction::assert( + "typeof Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { dateStyle: 'short' }) === 'string'", + ), + TestAction::assert( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { dateStyle: 'short' }).length > 0", + ), + ]); +} From 3f5d49e819ff6be3fcd31a4f9aa533e64b746182 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Fri, 20 Mar 2026 23:26:21 +0530 Subject: [PATCH 2/3] fix(intl): align Temporal.PlainYearMonth.prototype.toLocaleString with ECMA-402 --- core/engine/src/builtins/date/utils.rs | 8 -- .../builtins/temporal/plain_year_month/mod.rs | 81 +++++++++++++++++-- .../temporal/plain_year_month/tests.rs | 19 +++++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/core/engine/src/builtins/date/utils.rs b/core/engine/src/builtins/date/utils.rs index 400ff1065c1..964c63e541f 100644 --- a/core/engine/src/builtins/date/utils.rs +++ b/core/engine/src/builtins/date/utils.rs @@ -442,14 +442,6 @@ pub(super) fn make_date(day: f64, time: f64) -> f64 { tv } -#[cfg(feature = "intl")] -pub(crate) fn timestamp_for_first_of_month_utc(year: i32, month: u8) -> f64 { - debug_assert!((1..=12).contains(&month)); - let day = make_day(f64::from(year), f64::from(month - 1), 1.0); - let time = make_time(0.0, 0.0, 0.0, 0.0); - time_clip(make_date(day, time)) -} - /// Abstract operation `MakeFullYear ( year )` /// /// More info: diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index 336245d8c4e..c34ccad63ea 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -768,8 +768,10 @@ impl PlainYearMonth { args: &[JsValue], context: &mut Context, ) -> JsResult { + // 1. Let plainYearMonth be the this value. + // 2. Perform ? RequireInternalSlot(plainYearMonth, [[InitializedTemporalYearMonth]]). let object = this.as_object(); - let year_month = object + let plain_year_month = object .as_ref() .and_then(JsObject::downcast_ref::) .ok_or_else(|| { @@ -778,17 +780,80 @@ impl PlainYearMonth { #[cfg(feature = "intl")] { - use crate::builtins::{ - date::utils::timestamp_for_first_of_month_utc, - intl::date_time_format::{FormatDefaults, FormatType, format_date_time_locale}, + use crate::builtins::intl::date_time_format::{ + FormatDefaults, FormatType, format_date_time_locale, }; + use temporal_rs::TimeZone; let locales = args.get_or_undefined(0); let options = args.get_or_undefined(1); - let timestamp = - timestamp_for_first_of_month_utc(year_month.inner.year(), year_month.inner.month()); + + // From Temporal: `Temporal.PlainYearMonth.prototype.toLocaleString` steps (ECMA-402 integration). + // Source: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring + // 3. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). + // 4. Return ? FormatDateTime(dateFormat, plainYearMonth). + // Delegation: `format_date_time_locale` implements CreateDateTimeFormat + FormatDateTime + // and consumes the precomputed epochNs for the Temporal plain anchor. + + // ECMA-402 Temporal integration plumbing (HandleDateTimeTemporalYearMonth). + // + // Source: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimetemporalyearmonth + // + // 1. If temporalYearMonth.[[Calendar]] is not equal to dateTimeFormat.[[Calendar]], throw a RangeError exception. + // 2. Let isoDateTime be CombineISODateAndTimeRecord(temporalYearMonth.[[ISODate]], NoonTimeRecord()). + // 3. Let epochNs be GetUTCEpochNanoseconds(isoDateTime). + // 4. Let format be dateTimeFormat.[[TemporalPlainYearMonthFormat]]. + // 5. If format is null, throw a TypeError exception. + // 6. Return Value Format Record { [[Format]]: format, [[EpochNanoseconds]]: epochNs, [[IsPlain]]: true }. + // + // Delegation note: + // - In this implementation we compute epochNs (steps 2–3), validate Intl `calendar` (step 1), + // and delegate the ECMA-402 formatting to `format_date_time_locale`. + let temporal_calendar = plain_year_month.inner.calendar().identifier(); + + // Ensure Intl inputs match HandleDateTimeTemporalYearMonth expectations. + let options_obj = get_options_object(options)?; + let user_calendar = options_obj.get(js_string!("calendar"), context)?; + + if user_calendar.is_undefined() { + options_obj.create_data_property_or_throw( + js_string!("calendar"), + JsValue::from(JsString::from(temporal_calendar)), + context, + )?; + } else { + // Intl's `calendar` option is parsed from ToString, so we do the same for comparison. + let user_calendar = user_calendar.to_string(context)?.to_std_string_escaped(); + if user_calendar != temporal_calendar { + return Err( + JsNativeError::range() + .with_message("Temporal.PlainYearMonth calendar must match Intl.DateTimeFormat calendar.") + .into(), + ); + } + } + + options_obj.create_data_property_or_throw( + js_string!("timeZone"), + // Temporal plain values ignore user-provided `timeZone` and always use "+00:00". + JsValue::from(js_string!("+00:00")), + context, + )?; + + // Compute epochNs from isoDate + NoonTimeRecord (steps 2-3), then pass it to the shared + // ECMA-402 formatting pipeline as epoch milliseconds. + let epoch_ns = plain_year_month + .inner + .epoch_ns_for_with_provider( + TimeZone::utc_with_provider(context.timezone_provider()), + context.timezone_provider(), + ) + .map_err(|e| JsNativeError::range().with_message(e.to_string()))?; + let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; + + // Delegate the Intl formatting pipeline to the shared ECMA-402 implementation. format_date_time_locale( locales, - options, + &options_obj.into(), FormatType::Date, FormatDefaults::Date, timestamp, @@ -798,7 +863,7 @@ impl PlainYearMonth { #[cfg(not(feature = "intl"))] { - Ok(JsString::from(year_month.inner.to_string()).into()) + Ok(JsString::from(plain_year_month.inner.to_string()).into()) } } diff --git a/core/engine/src/builtins/temporal/plain_year_month/tests.rs b/core/engine/src/builtins/temporal/plain_year_month/tests.rs index 9d87b328a50..ec06d02d696 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/tests.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -40,3 +40,22 @@ fn to_locale_string_options_affect_output() { ), ]); } + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_ignores_time_zone_for_plain_values() { + run_test_actions([TestAction::assert( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { timeZone: 'America/New_York' }) === \ + Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { timeZone: '+00:00' })", + )]); +} + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_incompatible_calendar_throws() { + run_test_actions([TestAction::assert_native_error( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { calendar: 'japanese' })", + JsNativeErrorKind::Range, + "Temporal.PlainYearMonth calendar must match Intl.DateTimeFormat calendar.", + )]); +} From e887556fc20b5e70f1827621e5c31d7dbe16d581 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Sat, 21 Mar 2026 12:50:47 +0530 Subject: [PATCH 3/3] fix(intl): avoid Date style defaults in PlainYearMonth.toLocaleString --- .../src/builtins/intl/date_time_format/mod.rs | 61 ++++++++++++++++--- .../builtins/temporal/plain_year_month/mod.rs | 37 +++++++++-- .../temporal/plain_year_month/tests.rs | 18 ++++++ 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/core/engine/src/builtins/intl/date_time_format/mod.rs b/core/engine/src/builtins/intl/date_time_format/mod.rs index cc692602c79..2d3d219202e 100644 --- a/core/engine/src/builtins/intl/date_time_format/mod.rs +++ b/core/engine/src/builtins/intl/date_time_format/mod.rs @@ -1011,6 +1011,25 @@ fn unwrap_date_time_format( .into()) } +fn format_date_time_locale_after_coerce( + locales: &JsValue, + options: JsObject, + format_type: FormatType, + defaults: FormatDefaults, + timestamp: f64, + context: &mut Context, +) -> JsResult { + // `options` is already normalized/coerced by the caller. + let options_value = options.into(); + let dtf = create_date_time_format(locales, &options_value, format_type, defaults, context)?; + let x = time_clip(timestamp); + if x.is_nan() { + return Err(js_error!(RangeError: "formatted date cannot be NaN")); + } + let result = format_timestamp_with_dtf(&dtf, x, context)?; + Ok(JsValue::from(result)) +} + /// Shared helper used by Date.prototype.toLocaleString, /// Date.prototype.toLocaleDateString, and Date.prototype.toLocaleTimeString. /// Applies `ToDateTimeOptions` defaults, calls [`create_date_time_format`], and formats @@ -1043,13 +1062,37 @@ pub(crate) fn format_date_time_locale( context, )?; } - let options_value = options.into(); - let dtf = create_date_time_format(locales, &options_value, format_type, defaults, context)?; - // FormatDateTime steps 1–2: TimeClip and NaN check (format_timestamp_with_dtf does ToLocalTime + format only). - let x = time_clip(timestamp); - if x.is_nan() { - return Err(js_error!(RangeError: "formatted date cannot be NaN")); - } - let result = format_timestamp_with_dtf(&dtf, x, context)?; - Ok(JsValue::from(result)) + format_date_time_locale_after_coerce( + locales, + options, + format_type, + defaults, + timestamp, + context, + ) +} + +/// Like [`format_date_time_locale`], but does not inject `"long"` `dateStyle` / `timeStyle`. +/// +/// Callers that need Temporal `PlainYearMonth` default presentation should supply explicit +/// `year` / `month` (or `dateStyle`) on the options object so `CreateDateTimeFormat` does not apply +/// full date defaults that include `day`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn format_date_time_locale_no_implicit_styles( + locales: &JsValue, + options: &JsValue, + format_type: FormatType, + defaults: FormatDefaults, + timestamp: f64, + context: &mut Context, +) -> JsResult { + let options = coerce_options_to_object(options, context)?; + format_date_time_locale_after_coerce( + locales, + options, + format_type, + defaults, + timestamp, + context, + ) } diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index c34ccad63ea..b4db0ad9146 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -781,7 +781,7 @@ impl PlainYearMonth { #[cfg(feature = "intl")] { use crate::builtins::intl::date_time_format::{ - FormatDefaults, FormatType, format_date_time_locale, + FormatDefaults, FormatType, format_date_time_locale_no_implicit_styles, }; use temporal_rs::TimeZone; let locales = args.get_or_undefined(0); @@ -791,8 +791,8 @@ impl PlainYearMonth { // Source: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring // 3. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). // 4. Return ? FormatDateTime(dateFormat, plainYearMonth). - // Delegation: `format_date_time_locale` implements CreateDateTimeFormat + FormatDateTime - // and consumes the precomputed epochNs for the Temporal plain anchor. + // Delegation: `format_date_time_locale_no_implicit_styles` — same as `Date`'s path but + // without injecting `dateStyle`/`timeStyle: "long"` (that would include the ISO reference day). // ECMA-402 Temporal integration plumbing (HandleDateTimeTemporalYearMonth). // @@ -807,7 +807,7 @@ impl PlainYearMonth { // // Delegation note: // - In this implementation we compute epochNs (steps 2–3), validate Intl `calendar` (step 1), - // and delegate the ECMA-402 formatting to `format_date_time_locale`. + // and delegate the ECMA-402 formatting to `format_date_time_locale_no_implicit_styles`. let temporal_calendar = plain_year_month.inner.calendar().identifier(); // Ensure Intl inputs match HandleDateTimeTemporalYearMonth expectations. @@ -839,6 +839,33 @@ impl PlainYearMonth { context, )?; + // When no explicit style/fields are provided, supply `year` + `month` so + // `CreateDateTimeFormat` does not apply full date defaults (which include `day`). + // We use `month: "short"` (instead of `"numeric"`) because the current Intl formatter can + // produce compact numeric output like `12/24`, which fails Test262's full-year expectation + // (`default-does-not-include-day-time-and-time-zone-name.js` requires `2024` to appear). + let date_style = options_obj.get(js_string!("dateStyle"), context)?; + let time_style = options_obj.get(js_string!("timeStyle"), context)?; + if date_style.is_undefined() && time_style.is_undefined() { + if options_obj.get(js_string!("year"), context)?.is_undefined() { + options_obj.create_data_property_or_throw( + js_string!("year"), + JsValue::from(js_string!("numeric")), + context, + )?; + } + if options_obj + .get(js_string!("month"), context)? + .is_undefined() + { + options_obj.create_data_property_or_throw( + js_string!("month"), + JsValue::from(js_string!("short")), + context, + )?; + } + } + // Compute epochNs from isoDate + NoonTimeRecord (steps 2-3), then pass it to the shared // ECMA-402 formatting pipeline as epoch milliseconds. let epoch_ns = plain_year_month @@ -851,7 +878,7 @@ impl PlainYearMonth { let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; // Delegate the Intl formatting pipeline to the shared ECMA-402 implementation. - format_date_time_locale( + format_date_time_locale_no_implicit_styles( locales, &options_obj.into(), FormatType::Date, diff --git a/core/engine/src/builtins/temporal/plain_year_month/tests.rs b/core/engine/src/builtins/temporal/plain_year_month/tests.rs index ec06d02d696..b854c3a48ba 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/tests.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -50,6 +50,24 @@ fn to_locale_string_ignores_time_zone_for_plain_values() { )]); } +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_default_excludes_reference_day_time_and_zone_name() { + // Mirrors test262 `default-does-not-include-day-time-and-time-zone-name.js`. + run_test_actions([TestAction::assert( + "(() => { \ + const p = new Temporal.PlainYearMonth(2024, 12, 'iso8601', 26); \ + const r = p.toLocaleString('en-u-ca-iso8601', { timeZone: 'UTC' }); \ + return r.includes('2024') \ + && (r.includes('12') || r.includes('Dec')) \ + && !r.includes('26') \ + && !r.includes('00') \ + && !r.includes('UTC') \ + && !r.includes('Coordinated Universal Time'); \ + })()", + )]); +} + #[cfg(feature = "intl")] #[test] fn to_locale_string_incompatible_calendar_throws() {