diff --git a/core/engine/src/builtins/date/mod.rs b/core/engine/src/builtins/date/mod.rs index cb6b81944d4..a7c2f8c2f66 100644 --- a/core/engine/src/builtins/date/mod.rs +++ b/core/engine/src/builtins/date/mod.rs @@ -8,7 +8,7 @@ //! [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date use crate::{ - Context, JsArgs, JsData, JsError, JsResult, JsString, + Context, JsArgs, JsData, JsResult, JsString, builtins::{ BuiltInBuilder, BuiltInConstructor, BuiltInObject, IntrinsicObject, date::utils::{ @@ -1616,16 +1616,51 @@ impl Date { /// More information: /// - [MDN documentation][mdn] /// - /// [spec]: https://tc39.es/ecma262/#sec-date.prototype.tolocaledatestring + /// [spec]: https://tc39.es/ecma402/#sup-date.prototype.tolocaledatestring /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString + #[allow( + unused_variables, + reason = "`args` and `context` are used when the `intl` feature is enabled" + )] pub(crate) fn to_locale_date_string( - _this: &JsValue, - _args: &[JsValue], - _context: &mut Context, + this: &JsValue, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - Err(JsError::from_opaque(JsValue::new(js_string!( - "Function Unimplemented" - )))) + #[cfg(feature = "intl")] + { + use crate::builtins::intl::date_time_format::{ + FormatDefaults, FormatType, format_date_time_locale, + }; + // 1. Let dateObject be the this value. + // 2. Perform ? RequireInternalSlot(dateObject, [[DateValue]]). + // 3. Let x be dateObject.[[DateValue]]. + let t = this + .as_object() + .and_then(|obj| obj.downcast_ref::().as_deref().copied()) + .ok_or_else(|| JsNativeError::typ().with_message("'this' is not a Date"))? + .0; + // 4. If x is NaN, return "Invalid Date". + if t.is_nan() { + return Ok(JsValue::new(js_string!("Invalid Date"))); + } + // 5. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). + // 6. Return ! FormatDateTime(dateFormat, x). + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + format_date_time_locale( + locales, + options, + FormatType::Date, + FormatDefaults::Date, + t, + context, + ) + } + #[cfg(not(feature = "intl"))] + { + Self::to_string(this, &[], context) + } } /// [`Date.prototype.toLocaleString()`][spec]. @@ -1635,16 +1670,51 @@ impl Date { /// More information: /// - [MDN documentation][mdn] /// - /// [spec]: https://tc39.es/ecma262/#sec-date.prototype.tolocalestring + /// [spec]: https://tc39.es/ecma402/#sup-date.prototype.tolocalestring /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/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], - _context: &mut Context, + this: &JsValue, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - Err(JsError::from_opaque(JsValue::new(js_string!( - "Function Unimplemented]" - )))) + #[cfg(feature = "intl")] + { + use crate::builtins::intl::date_time_format::{ + FormatDefaults, FormatType, format_date_time_locale, + }; + // 1. Let dateObject be the this value. + // 2. Perform ? RequireInternalSlot(dateObject, [[DateValue]]). + // 3. Let x be dateObject.[[DateValue]]. + let t = this + .as_object() + .and_then(|obj| obj.downcast_ref::().as_deref().copied()) + .ok_or_else(|| JsNativeError::typ().with_message("'this' is not a Date"))? + .0; + // 4. If x is NaN, return "Invalid Date". + if t.is_nan() { + return Ok(JsValue::new(js_string!("Invalid Date"))); + } + // 5. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, any, all). + // 6. Return ! FormatDateTime(dateFormat, x). + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + format_date_time_locale( + locales, + options, + FormatType::Any, + FormatDefaults::All, + t, + context, + ) + } + #[cfg(not(feature = "intl"))] + { + Self::to_string(this, &[], context) + } } /// [`Date.prototype.toLocaleTimeString()`][spec]. @@ -1655,16 +1725,51 @@ impl Date { /// More information: /// - [MDN documentation][mdn] /// - /// [spec]: https://tc39.es/ecma262/#sec-date.prototype.tolocaletimestring + /// [spec]: https://tc39.es/ecma402/#sup-date.prototype.tolocaletimestring /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString + #[allow( + unused_variables, + reason = "`args` and `context` are used when the `intl` feature is enabled" + )] pub(crate) fn to_locale_time_string( - _this: &JsValue, - _args: &[JsValue], - _context: &mut Context, + this: &JsValue, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - Err(JsError::from_opaque(JsValue::new(js_string!( - "Function Unimplemented]" - )))) + #[cfg(feature = "intl")] + { + use crate::builtins::intl::date_time_format::{ + FormatDefaults, FormatType, format_date_time_locale, + }; + // 1. Let dateObject be the this value. + // 2. Perform ? RequireInternalSlot(dateObject, [[DateValue]]). + // 3. Let x be dateObject.[[DateValue]]. + let t = this + .as_object() + .and_then(|obj| obj.downcast_ref::().as_deref().copied()) + .ok_or_else(|| JsNativeError::typ().with_message("'this' is not a Date"))? + .0; + // 4. If x is NaN, return "Invalid Date". + if t.is_nan() { + return Ok(JsValue::new(js_string!("Invalid Date"))); + } + // 5. Let timeFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, time, time). + // 6. Return ! FormatDateTime(timeFormat, x). + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + format_date_time_locale( + locales, + options, + FormatType::Time, + FormatDefaults::Time, + t, + context, + ) + } + #[cfg(not(feature = "intl"))] + { + Self::to_string(this, &[], context) + } } /// [`Date.prototype.toString()`][spec]. diff --git a/core/engine/src/builtins/date/tests.rs b/core/engine/src/builtins/date/tests.rs index 52366832a82..a6b6aed6d2e 100644 --- a/core/engine/src/builtins/date/tests.rs +++ b/core/engine/src/builtins/date/tests.rs @@ -943,3 +943,52 @@ fn date_parse_hour24_validation() { TestAction::assert("isNaN(Date.parse('2024-01-01T24:00:00.001Z'))"), ]); } + +#[test] +#[cfg(feature = "intl")] +fn date_proto_to_locale_string_intl() { + run_test_actions([ + // Invalid receiver: spec requires TypeError + TestAction::assert_native_error( + "Date.prototype.toLocaleString.call({})", + JsNativeErrorKind::Type, + "'this' is not a Date", + ), + TestAction::assert_native_error( + "Date.prototype.toLocaleDateString.call({})", + JsNativeErrorKind::Type, + "'this' is not a Date", + ), + TestAction::assert_native_error( + "Date.prototype.toLocaleTimeString.call({})", + JsNativeErrorKind::Type, + "'this' is not a Date", + ), + TestAction::assert_eq("new Date(NaN).toLocaleString()", js_str!("Invalid Date")), + TestAction::assert("typeof new Date(2020, 6, 8).toLocaleString() === 'string'"), + TestAction::assert("typeof new Date(2020, 6, 8).toLocaleDateString() === 'string'"), + TestAction::assert("typeof new Date(2020, 6, 8).toLocaleTimeString() === 'string'"), + TestAction::assert("typeof new Date(0).toLocaleString('en-US') === 'string'"), + TestAction::assert("typeof new Date(0).toLocaleDateString('en-US') === 'string'"), + TestAction::assert("typeof new Date(0).toLocaleDateString('de-DE') === 'string'"), + TestAction::assert("typeof new Date(0).toLocaleTimeString('en-US') === 'string'"), + // Prove locale pipeline: different locales produce different output + TestAction::assert( + "new Date(0).toLocaleDateString('en-US') !== new Date(0).toLocaleDateString('de-DE')", + ), + TestAction::assert( + "new Date(0).toLocaleString('en-US') !== new Date(0).toLocaleString('de-DE')", + ), + TestAction::assert( + "new Date(0).toLocaleTimeString('en-US') !== new Date(0).toLocaleTimeString('de-DE')", + ), + // Prove ToDateTimeOptions pipeline: options affect output + TestAction::assert( + "typeof new Date(0).toLocaleDateString('en-US', { dateStyle: 'short' }) === 'string'", + ), + // Prove output is a string and not empty + TestAction::assert( + "new Date(0).toLocaleDateString('en-US', { dateStyle: 'short' }).length > 0", + ), + ]); +} 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 f562da44529..cc692602c79 100644 --- a/core/engine/src/builtins/intl/date_time_format/mod.rs +++ b/core/engine/src/builtins/intl/date_time_format/mod.rs @@ -177,14 +177,19 @@ impl BuiltInConstructor for DateTimeFormat { let options = args.get_or_undefined(1); // 2. Let dateTimeFormat be ? CreateDateTimeFormat(newTarget, locales, options, any, date). - let date_time_format = create_date_time_format( - new_target_inner, + let dtf = create_date_time_format( locales, options, FormatType::Any, FormatDefaults::Date, context, )?; + let prototype = get_prototype_from_constructor( + new_target_inner, + StandardConstructors::date_time_format, + context, + )?; + let date_time_format = JsObject::from_proto_and_data(prototype, dtf); // 3. If the implementation supports the normative optional constructor mode of 4.3 Note 1, then // a. Let this be the this value. @@ -273,61 +278,13 @@ impl DateTimeFormat { }; // 5. Return ? FormatDateTime(dtf, x). - - // A.O 11.5.6 PartitionDateTimePattern - - // 1. Let x be TimeClip(x). - // 2. If x is NaN, throw a RangeError exception. + // A.O 11.5.6 PartitionDateTimePattern: 1. TimeClip(x). 2. If NaN throw. Then ToLocalTime and format. let x = time_clip(x); if x.is_nan() { return Err(js_error!(RangeError: "formatted date cannot be NaN")); } - - // A.O 11.5.12 ToLocalTime - let time_zone_offset = match dtf.borrow().data().time_zone { - // 1. If IsTimeZoneOffsetString(timeZoneIdentifier) is true, then - // a. Let offsetNs be ParseTimeZoneOffsetString(timeZoneIdentifier). - FormatTimeZone::UtcOffset(offset) => offset.to_seconds(), - // 2. Else, - FormatTimeZone::Identifier((_, time_zone_id)) => { - // Shift x in epoch milliseconds to epoch nanoseconds - let epoch_ns = x as i128 * 1_000_000; - // a. Assert: GetAvailableNamedTimeZoneIdentifier(timeZoneIdentifier) is not empty. - // b. Let offsetNs be GetNamedTimeZoneOffsetNanoseconds(timeZoneIdentifier, epochNs). - let offset_seconds = context - .timezone_provider() - .transition_nanoseconds_for_utc_epoch_nanoseconds(time_zone_id, epoch_ns) - .map_err(|_e| js_error!(RangeError: "unable to determine transition nanoseconds"))?; - offset_seconds.0 as i32 - } - }; - - // 3. Let tz be ℝ(epochNs) + offsetNs. - let tz = x + f64::from(time_zone_offset * 1_000); - - // TODO: Non-gregorian calendar support? - // 4. If calendar is "gregory", then - // a. Return a ToLocalTime Record with fields calculated from tz according to Table 17. - // 5. Else, - // a. Return a ToLocalTime Record with the fields calculated from tz for - // the given calendar. The calculations should use best available - // information about the specified calendar. - let fields = ToLocalTime::from_local_epoch_milliseconds(tz)?; - - let formatter = dtf.borrow().data().formatter.clone(); - - let dt = fields.to_formattable_datetime()?; - let tz_info = dtf.borrow().data().time_zone.to_time_zone_info(); - let tz_info_at_time = tz_info.at_date_time_iso(dt); - - let zdt = ZonedDateTime { - date: dt.date, - time: dt.time, - zone: tz_info_at_time, - }; - let result = formatter.format(&zdt).to_string(); - - Ok(JsString::from(result).into()) + let result = format_timestamp_with_dtf(dtf.borrow().data(), x, context)?; + Ok(JsValue::from(result)) }, dtf_clone, ), @@ -567,23 +524,16 @@ impl ToLocalTime { // ==== Abstract Operations ==== -fn create_date_time_format( - new_target: &JsValue, +/// Creates a [`DateTimeFormat`] struct (internal slots only). The constructor wraps this in a +/// `JsObject` with the correct prototype; Date.prototype.toLocaleString (and friends) use it +/// directly with [`format_timestamp_with_dtf`] without allocating a JS object. +pub(crate) fn create_date_time_format( locales: &JsValue, options: &JsValue, date_time_format_type: FormatType, defaults: FormatDefaults, context: &mut Context, -) -> JsResult { - // 1. Let dateTimeFormat be ? OrdinaryCreateFromConstructor(newTarget, "%Intl.DateTimeFormat.prototype%", - // « [[InitializedDateTimeFormat]], [[Locale]], [[Calendar]], [[NumberingSystem]], [[TimeZone]], - // [[HourCycle]], [[DateStyle]], [[TimeStyle]], [[DateTimeFormat]], [[BoundFormat]] »). - let prototype = get_prototype_from_constructor( - new_target, - StandardConstructors::date_time_format, - context, - )?; - +) -> JsResult { // 2. Let hour12 be undefined. <- TODO // 3. Let modifyResolutionOptions be a new Abstract Closure with parameters (options) that captures hour12 and performs the following steps when called: // a. Set hour12 to options.[[hour12]]. @@ -859,22 +809,93 @@ fn create_date_time_format( ) .map_err(|e| JsNativeError::range().with_message(format!("failed to load formatter: {e}")))?; - Ok(JsObject::from_proto_and_data( - prototype, - DateTimeFormat { - locale: resolved_locale, - calendar_algorithm: intl_options.preferences.calendar_algorithm, - numbering_system: intl_options.preferences.numbering_system, - hour_cycle: intl_options.preferences.hour_cycle, - date_style, - time_style, - time_zone, - fieldset, - formatter, - bound_format: None, - resolved_options: None, - }, - )) + Ok(DateTimeFormat { + locale: resolved_locale, + calendar_algorithm: intl_options.preferences.calendar_algorithm, + numbering_system: intl_options.preferences.numbering_system, + hour_cycle: intl_options.preferences.hour_cycle, + date_style, + time_style, + time_zone, + fieldset, + formatter, + bound_format: None, + resolved_options: None, + }) +} + +/// Formats a timestamp (epoch milliseconds) using the given [`DateTimeFormat`] internals. +/// +/// This is the shared implementation used by: +/// - the bound `format` function created in `get_format`, and +/// - [`format_date_time_locale`] used by `Date.prototype.toLocaleString` (and friends). +/// +/// It corresponds to the *post*-`TimeClip` portion of +/// [`FormatDateTime(dtf, x)`](https://tc39.es/ecma402/#sec-formatdatetime), +/// and the `ToLocalTime` / `PartitionDateTimePattern` logic from +/// [11.5.6](https://tc39.es/ecma402/#sec-partitiondatetimepattern) and +/// [11.5.12](https://tc39.es/ecma402/#sec-tolocaltime). +/// +/// Callers must have already applied `TimeClip` and `NaN` check +/// (`FormatDateTime` steps 1–2). This helper implements: +/// +/// 11.5.6 `PartitionDateTimePattern` ( dtf, x ) +/// 1. Let x be TimeClip(x). (Done by caller) +/// 2. If x is `NaN`, throw a `RangeError` exception. (Done by caller) +/// 3. Let epochNanoseconds be ℤ(ℝ(x) × 10^6). +/// 4. Let timeZone be dtf.[[`TimeZone`]]. +/// 5. Let offsetNs be GetOffsetNanosecondsFor(timeZone, epochNanoseconds). +/// 6. Let tz be 𝔽(ℝ(x) + ℝ(offsetNs) / 10^6). +/// +/// Then calls `ToLocalTime::from_local_epoch_milliseconds` to obtain calendar fields, +/// and formats the resulting `ZonedDateTime` with ICU4X. +fn format_timestamp_with_dtf( + dtf: &DateTimeFormat, + timestamp: f64, + context: &mut Context, +) -> JsResult { + // PartitionDateTimePattern ( dtf, x ) step 3: + // Let epochNanoseconds be ℤ(ℝ(x) × 10^6). + // + // NOTE: `timestamp` is already `TimeClip`'d by the caller and represents *UTC epoch milliseconds*. + let epoch_ns = timestamp as i128 * 1_000_000; + + // PartitionDateTimePattern ( dtf, x ) step 4: + // Let timeZone be dtf.[[`TimeZone`]]. + let time_zone = &dtf.time_zone; + + // PartitionDateTimePattern ( dtf, x ) step 5: + // Let offsetNs be GetOffsetNanosecondsFor(timeZone, epochNanoseconds). + // + // NOTE: the spec describes the offset in *nanoseconds*. Internally, we obtain/normalize it to + // seconds (and then milliseconds) for use with `ToLocalTime::from_local_epoch_milliseconds`. + let time_zone_offset_seconds = match time_zone { + FormatTimeZone::UtcOffset(offset) => offset.to_seconds(), + FormatTimeZone::Identifier((_, time_zone_id)) => { + let offset_seconds = context + .timezone_provider() + .transition_nanoseconds_for_utc_epoch_nanoseconds(*time_zone_id, epoch_ns) + .map_err( + |_e| js_error!(RangeError: "unable to determine transition nanoseconds"), + )?; + offset_seconds.0 as i32 + } + }; + + // PartitionDateTimePattern ( dtf, x ) step 6: + // Let tz be 𝔽(ℝ(x) + ℝ(offsetNs) / 10^6). + let tz = timestamp + f64::from(time_zone_offset_seconds * 1_000); + let fields = ToLocalTime::from_local_epoch_milliseconds(tz)?; + let dt = fields.to_formattable_datetime()?; + let tz_info = time_zone.to_time_zone_info(); + let tz_info_at_time = tz_info.at_date_time_iso(dt); + let zdt = ZonedDateTime { + date: dt.date, + time: dt.time, + zone: tz_info_at_time, + }; + let result = dtf.formatter.format(&zdt).to_string(); + Ok(JsString::from(result)) } fn date_time_style_format( @@ -928,10 +949,14 @@ pub(crate) enum FormatType { Any, } +/// Indicates which default fields should be applied when `ToDateTimeOptions` +/// determines defaults are needed. `All` applies both date and time defaults. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum FormatDefaults { Date, Time, + /// Apply both date and time defaults (e.g. for `toLocaleString`). + All, } /// Abstract operation [`UnwrapDateTimeFormat ( dtf )`][spec]. @@ -985,3 +1010,46 @@ fn unwrap_date_time_format( .with_message("object was not an initialized `Intl.DateTimeFormat` object") .into()) } + +/// Shared helper used by Date.prototype.toLocaleString, +/// Date.prototype.toLocaleDateString, and Date.prototype.toLocaleTimeString. +/// Applies `ToDateTimeOptions` defaults, calls [`create_date_time_format`], and formats +/// the timestamp via [`format_timestamp_with_dtf`] without allocating a JS object. +#[allow(clippy::too_many_arguments)] +pub(crate) fn format_date_time_locale( + locales: &JsValue, + options: &JsValue, + format_type: FormatType, + defaults: FormatDefaults, + timestamp: f64, + 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")), + context, + )?; + } + 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, + )?; + } + 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)) +}