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 1090907f792..356beccd5b0 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 @@ -1025,31 +1044,192 @@ pub(crate) fn format_date_time_locale( context: &mut Context, ) -> JsResult { let options = coerce_options_to_object(options, context)?; - if format_type != FormatType::Time - && get_option::(&options, js_string!("dateStyle"), context)?.is_none() - { - options.create_data_property_or_throw( - js_string!("dateStyle"), - JsValue::from(js_string!("long")), + format_date_time_locale_after_coerce( + locales, + options, + format_type, + defaults, + timestamp, + context, + ) +} + +/// 15.6.16 `HandleDateTimeTemporalYearMonth ( dateTimeFormat, temporalYearMonth )` +/// +/// Prepares a `Temporal.PlainYearMonth` for formatting by `Intl.DateTimeFormat`. +/// +/// NOTE: This structure mirrors the spec steps to allow reuse of the same +/// pattern across other Temporal type handlers (`PlainDate`, `PlainMonthDay`, etc.). +/// +/// [spec]: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimetemporalyearmonth +fn handle_date_time_temporal_year_month( + temporal_year_month: &JsValue, + locales: &JsValue, + options: &JsValue, + context: &mut Context, +) -> JsResult { + use crate::builtins::temporal::PlainYearMonth; + use temporal_rs::TimeZone; + + let object = temporal_year_month.as_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.") + })?; + + // 1. If temporalYearMonth.[[Calendar]] is not equal to dateTimeFormat.[[Calendar]], + // throw a RangeError exception. + let temporal_calendar = plain_year_month.inner.calendar().identifier(); + + let options_obj = coerce_options_to_object(options, context)?; + 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 { + 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()); + } } - if format_type != FormatType::Date - && get_option::(&options, js_string!("timeStyle"), context)?.is_none() - { - options.create_data_property_or_throw( - js_string!("timeStyle"), - JsValue::from(js_string!("long")), - context, - )?; + + // Implementation note: Temporal plain values always use UTC. + // Override any user-provided timeZone before CreateDateTimeFormat. + options_obj.create_data_property_or_throw( + js_string!("timeZone"), + JsValue::from(js_string!("+00:00")), + context, + )?; + + // Implementation note: When no explicit style/fields are provided, supply + // year + month so CreateDateTimeFormat does not apply full date defaults + // (which include day). + 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, + )?; + } } - 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")); + + // 2. Let isoDateTime be CombineISODateAndTimeRecord(temporalYearMonth.[[ISODate]], NoonTimeRecord()). + // 3. Let epochNs be GetUTCEpochNanoseconds(isoDateTime). + 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: temporal_rs::TemporalError| { + JsNativeError::range().with_message(e.to_string()) + })?; + let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; + + // 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 }. + // + // These steps are delegated to the ECMA-402 formatting pipeline via + // `format_date_time_locale_after_coerce`, which handles CreateDateTimeFormat + // and FormatDateTime internally. + format_date_time_locale_after_coerce( + locales, + options_obj, + FormatType::Date, + FormatDefaults::Date, + timestamp, + context, + ) +} + +/// 15.6.22 `HandleDateTimeValue ( dateTimeFormat, x )` +/// +/// Dispatches `x` to the appropriate handler based on its Temporal type for +/// formatting by `dateTimeFormat`. +/// +/// [spec]: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimevalue +pub(crate) fn handle_date_time_value( + x: &JsValue, + locales: &JsValue, + options: &JsValue, + context: &mut Context, +) -> JsResult { + use crate::builtins::temporal::{ + Instant, PlainDate, PlainDateTime, PlainMonthDay, PlainTime, PlainYearMonth, ZonedDateTime, + }; + + let Some(obj) = x.as_object() else { + return Err(JsNativeError::typ() + .with_message("value is not a Temporal object") + .into()); + }; + + // 1. If x is a Number, return ? HandleDateTimeOthers(dateTimeFormat, x). + // NOTE: Not applicable — callers pass Temporal objects, not Numbers. + + // 2. If x has an [[InitializedTemporalDate]] internal slot, return ? HandleDateTimeTemporalDate(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalDate is not yet implemented")); } - let result = format_timestamp_with_dtf(&dtf, x, context)?; - Ok(JsValue::from(result)) + + // 3. If x has an [[InitializedTemporalYearMonth]] internal slot, return ? HandleDateTimeTemporalYearMonth(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return handle_date_time_temporal_year_month(x, locales, options, context); + } + + // 4. If x has an [[InitializedTemporalMonthDay]] internal slot, return ? HandleDateTimeTemporalMonthDay(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalMonthDay is not yet implemented")); + } + + // 5. If x has an [[InitializedTemporalTime]] internal slot, return ? HandleDateTimeTemporalTime(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalTime is not yet implemented")); + } + + // 6. If x has an [[InitializedTemporalDateTime]] internal slot, return ? HandleDateTimeTemporalDateTime(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalDateTime is not yet implemented")); + } + + // 7. If x has an [[InitializedTemporalInstant]] internal slot, return HandleDateTimeTemporalInstant(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalInstant is not yet implemented")); + } + + // 8. Assert: x has an [[InitializedTemporalZonedDateTime]] internal slot. + // 9. Throw a TypeError exception. + if obj.downcast_ref::().is_some() { + return Err(JsNativeError::typ() + .with_message("Temporal.ZonedDateTime is not allowed in Intl.DateTimeFormat") + .into()); + } + + Err(JsNativeError::typ() + .with_message("value is not a recognized Temporal object") + .into()) } diff --git a/core/engine/src/builtins/intl/date_time_format/tests.rs b/core/engine/src/builtins/intl/date_time_format/tests.rs index 66d97ccfd99..84723688752 100644 --- a/core/engine/src/builtins/intl/date_time_format/tests.rs +++ b/core/engine/src/builtins/intl/date_time_format/tests.rs @@ -65,3 +65,26 @@ fn dtf_basic() { TestAction::assert_eq("result === 'Sunday, 20 December 2020 at 14:23:16'", true), ]); } + +#[cfg(feature = "intl_bundled")] +#[test] +fn date_to_locale_string_style_does_not_force_missing_counterpart() { + run_test_actions([ + TestAction::run(indoc! {" + const date = new Date(Date.UTC(2024, 2, 10, 2, 30, 0, 0)); + + const dateOnly = date.toLocaleString('en-US', { dateStyle: 'short', timeZone: 'UTC' }); + const timeOnly = date.toLocaleString('en-US', { timeStyle: 'short', timeZone: 'UTC' }); + "}), + TestAction::assert_eq("dateOnly === '3/10/24'", true), + TestAction::assert_eq("/^2:30\\s?AM$/.test(timeOnly)", true), + TestAction::assert_eq( + "dateOnly.includes(':') || dateOnly.includes('AM') || dateOnly.includes('PM')", + false, + ), + TestAction::assert_eq( + "timeOnly.includes('/') || timeOnly.includes('-') || /January|February|March|April|May|June|July|August|September|October|November|December/.test(timeOnly)", + false, + ), + ]); +} 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..72f2d0785e4 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,44 @@ 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` may be unused depending on feature flags" + )] 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() - .and_then(JsObject::downcast_ref::) + // 1. Let plainYearMonth be the this value. + // 2. Perform ? RequireInternalSlot(plainYearMonth, [[InitializedTemporalYearMonth]]). + let () = this + .as_object() + .and_then(|o| o.downcast_ref::().map(|_| ())) .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::handle_date_time_value; + + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + + // 3. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). + // 4. Return ? FormatDateTime(dateFormat, plainYearMonth). + handle_date_time_value(this, locales, options, context) + } + + #[cfg(not(feature = "intl"))] + { + let plain_year_month = this + .as_object() + .and_then(|o| o.downcast_ref::().map(|ym| ym.inner.to_string())) + .expect("RequireInternalSlot already validated"); + Ok(JsString::from(plain_year_month).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.", + )]); +}