Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 52 additions & 9 deletions core/engine/src/builtins/intl/date_time_format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsValue> {
// `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
Expand Down Expand Up @@ -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<JsValue> {
let options = coerce_options_to_object(options, context)?;
format_date_time_locale_after_coerce(
locales,
options,
format_type,
defaults,
timestamp,
context,
)
}
132 changes: 127 additions & 5 deletions core/engine/src/builtins/temporal/plain_year_month/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ use super::{
to_temporal_duration,
};

#[cfg(feature = "temporal")]
#[cfg(test)]
mod tests;

/// The `Temporal.PlainYearMonth` built-in implementation
///
/// More information:
Expand Down Expand Up @@ -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<JsValue> {
// 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::<Self>)
.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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this should ideally not be inlined from HandleDateTimeValue.

The implementation here should probably stick as close to the specification as possible while also being able to expand to support the other temporal built-ins

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 ( )`
Expand Down
79 changes: 79 additions & 0 deletions core/engine/src/builtins/temporal/plain_year_month/tests.rs
Original file line number Diff line number Diff line change
@@ -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.",
)]);
}
Loading