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 1c328da5997..b4db0ad9146 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,21 +759,139 @@ 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 + // 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(|| { 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::intl::date_time_format::{ + FormatDefaults, FormatType, format_date_time_locale_no_implicit_styles, + }; + use temporal_rs::TimeZone; + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + + // 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_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). + // + // 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_no_implicit_styles`. + 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, + )?; + + // 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 + .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_no_implicit_styles( + locales, + &options_obj.into(), + FormatType::Date, + FormatDefaults::Date, + timestamp, + context, + ) + } + + #[cfg(not(feature = "intl"))] + { + Ok(JsString::from(plain_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..b854c3a48ba --- /dev/null +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -0,0 +1,79 @@ +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", + ), + ]); +} + +#[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_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() { + 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.", + )]); +}