Skip to content

Commit 393af3c

Browse files
proggeramlugRalphclaude
authored
fix(temporal): #5580 — Temporal toLocaleString rendering + reject bare 'islamic' calendar (#5730)
Two intl402/Temporal conformance gaps in the `toLocaleString`/calendar tail (#5580), clearing 29 test262 cases (intl402/Temporal 105 → 76 runtime-fails, parity 94.8% → 96.3%) with no regressions: 1. **Reject the bare `islamic` calendar in Temporal.** `temporal_rs` accepts `islamic` (no `-civil`/`-tbla`/`-umalqura` variant) by falling back to the tabular-Friday Hijri epoch, but per Intl-Era-monthcode a bare `islamic` is only valid in the `Intl.DateTimeFormat` constructor, not as a Temporal calendar identifier. `calendar_slot` / `calendar_identifier` now throw a `RangeError` for it, so `Temporal.PlainDate.from({ calendar: "islamic" })` and the other `from`/constructor paths reject as the spec requires. (`{PlainDate,PlainDateTime,PlainMonthDay,PlainYearMonth,ZonedDateTime}/from/islamic.js`) 2. **Render `Temporal.*.prototype.toLocaleString` instead of `[object Object]`.** The runtime `toLocaleString` tag-dispatcher (`js_object_default_to_locale_string`) now routes a Temporal value to its own per-type formatter and enforces the spec's calendar-mismatch `RangeError`. Output is derived from the calendar-aware accessors: since the ISO-8601 calendar *is* the proleptic Gregorian calendar those accessors are identical for an `iso8601` and a `gregory` instance of the same date, and `assert_locale_string_calendar` rejects every other calendar — so the rendering is calendar-agnostic, as the `calendar-mismatch` cases require. Fixes calendar-mismatch, era, default-includes/excludes-field, ignore-timezone, and resolved-time-zone cases across PlainDate/PlainDateTime/PlainTime/PlainYearMonth/PlainMonthDay/ ZonedDateTime. Known follow-up: the `locales`/`options` arguments are dropped by the `Expr::DateToLocaleString` HIR lowering before reaching the runtime, so the option-conflict `TypeError`s (`dateStyle`+`timeStyle`, component+style, a `timeZone` on a ZonedDateTime) and explicit non-default `calendar`/style formatting are not yet honored — threading those args through that lowering is the next step. Co-authored-by: Ralph <ralph@skelpo.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d359615 commit 393af3c

8 files changed

Lines changed: 148 additions & 11 deletions

File tree

crates/perry-runtime/src/object/native_call_method/object_proto.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 {
8484
let s = crate::date::js_date_to_locale_string(ts);
8585
return f64::from_bits(JSValue::string_ptr(s).bits());
8686
}
87+
// #5580: a `Temporal.*` value formats via its own `toLocaleString` (a
88+
// calendar-aware, type-specific rendering plus the spec's calendar-mismatch
89+
// `RangeError`) rather than the `[object Object]` default. The HIR lowers
90+
// `value.toLocaleString(...)` to the arg-less `Expr::DateToLocaleString`, so
91+
// the locale/options arguments are not visible here; the no-argument form is
92+
// dispatched through the Temporal method router.
93+
#[cfg(feature = "temporal")]
94+
if crate::temporal::is_temporal_value(receiver) {
95+
return crate::temporal::dispatch::call_method(receiver, "toLocaleString", &[]);
96+
}
8797
if !jsval.is_pointer() {
8898
return js_native_call_method(
8999
receiver,

crates/perry-runtime/src/temporal/options.rs

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ use temporal_rs::options::{
2121
use temporal_rs::parsers::Precision;
2222
use temporal_rs::partial::{PartialTime, PartialZonedDateTime};
2323
use temporal_rs::provider::TransitionDirection;
24-
use temporal_rs::{Calendar, MonthCode, PlainTime, TimeZone, TinyAsciiStr, UtcOffset};
24+
use temporal_rs::{
25+
Calendar, MonthCode, PlainDate, PlainDateTime, PlainMonthDay, PlainTime, PlainYearMonth,
26+
TimeZone, TinyAsciiStr, UtcOffset, ZonedDateTime,
27+
};
2528

2629
// ---- low-level JS object field reads --------------------------------------
2730

@@ -336,7 +339,9 @@ pub fn calendar_slot(v: f64) -> temporal_rs::Calendar {
336339
}
337340
let jv = JSValue::from_bits(v.to_bits());
338341
if jv.is_string() {
339-
return ok_or_throw(read_string(v).parse::<Calendar>());
342+
let s = read_string(v);
343+
reject_bare_islamic(&s);
344+
return ok_or_throw(s.parse::<Calendar>());
340345
}
341346
if let Some(tv) = super::temporal_value_ref(v) {
342347
return match tv {
@@ -360,13 +365,114 @@ pub fn calendar_slot(v: f64) -> temporal_rs::Calendar {
360365
/// value → its `[[Calendar]]`, anything else → TypeError).
361366
pub fn calendar_identifier(v: f64) -> temporal_rs::Calendar {
362367
if JSValue::from_bits(v.to_bits()).is_string() {
363-
return ok_or_throw(temporal_rs::Calendar::try_from_utf8(
364-
read_string(v).as_bytes(),
365-
));
368+
let s = read_string(v);
369+
reject_bare_islamic(&s);
370+
return ok_or_throw(temporal_rs::Calendar::try_from_utf8(s.as_bytes()));
366371
}
367372
calendar_slot(v)
368373
}
369374

375+
/// `islamic` (the bare Hijri identifier, with no `-civil` / `-tbla` /
376+
/// `-umalqura` variant) is *accepted* by `temporal_rs` — it falls back to the
377+
/// tabular-Friday epoch — but per the Intl-Era-monthcode proposal a bare
378+
/// `islamic` is recognized only by the `Intl.DateTimeFormat` constructor, never
379+
/// as a Temporal calendar identifier. Reject it with a `RangeError` so
380+
/// `Temporal.PlainDate.from({ calendar: "islamic", … })` and friends throw as
381+
/// the spec requires, instead of silently constructing a Hijri date.
382+
fn reject_bare_islamic(id: &str) {
383+
if id.eq_ignore_ascii_case("islamic") {
384+
range("unknown calendar: islamic is only supported by Intl.DateTimeFormat, not Temporal");
385+
}
386+
}
387+
388+
// ---- `Temporal.*.prototype.toLocaleString` ---------------------------------
389+
390+
/// `Temporal.*.prototype.toLocaleString` calendar check. A Temporal value
391+
/// renders only when its calendar matches the formatter's: the ISO-8601
392+
/// calendar (always permitted) or the calendar the locale resolves to. With no
393+
/// explicit `calendar` option that resolved calendar is the locale default,
394+
/// which Perry's `Intl.DateTimeFormat` reports as `gregory`. Any other calendar
395+
/// is a `RangeError` (the test262 `toLocaleString/calendar-mismatch` cases).
396+
///
397+
/// Only the calendar-bearing types (`PlainDate`/`PlainDateTime`/`PlainYearMonth`
398+
/// /`PlainMonthDay`/`ZonedDateTime`) call this; `Instant`/`PlainTime` have no
399+
/// calendar slot.
400+
///
401+
/// NOTE: the `locales`/`options` arguments — which could name a *different*
402+
/// calendar to validate against — are dropped by the `Expr::DateToLocaleString`
403+
/// HIR lowering before the call reaches the runtime, so only the locale-default
404+
/// calendar is consulted here. Honoring an explicit `calendar`/`dateStyle`/
405+
/// `timeStyle` option (and the spec's option-conflict `TypeError`s) is a
406+
/// follow-up that depends on threading those arguments through that lowering.
407+
pub fn assert_locale_string_calendar(instance_calendar: &str) {
408+
if instance_calendar != "iso8601" && instance_calendar != "gregory" {
409+
range("calendar mismatch: the value's calendar differs from the locale calendar");
410+
}
411+
}
412+
413+
/// `H:MM:SS AM/PM`, the en-US default wall-clock rendering shared by the
414+
/// time-bearing `toLocaleString` formatters below.
415+
fn locale_time_12h(hour: u8, minute: u8, second: u8) -> String {
416+
let (h12, suffix) = match hour {
417+
0 => (12, "AM"),
418+
1..=11 => (hour, "AM"),
419+
12 => (12, "PM"),
420+
_ => (hour - 12, "PM"),
421+
};
422+
format!("{h12}:{minute:02}:{second:02} {suffix}")
423+
}
424+
425+
// The valid-path formatters render straight from the calendar-aware accessors.
426+
// Because the ISO-8601 calendar *is* the proleptic Gregorian calendar, those
427+
// accessors return identical numbers for an `iso8601` and a `gregory` instance
428+
// of the same date — and `assert_locale_string_calendar` has already rejected every
429+
// other calendar — so the output is calendar-agnostic, exactly as the
430+
// `calendar-mismatch` conformance tests require (an ISO and a Gregorian
431+
// instance must format identically).
432+
433+
/// `M/D/YYYY` — `Temporal.PlainDate.prototype.toLocaleString` default form.
434+
pub fn plain_date_locale_string(d: &PlainDate) -> String {
435+
format!("{}/{}/{}", d.month(), d.day(), d.year())
436+
}
437+
438+
/// `M/D/YYYY, H:MM:SS AM/PM` — `Temporal.PlainDateTime` default form.
439+
pub fn plain_date_time_locale_string(dt: &PlainDateTime) -> String {
440+
format!(
441+
"{}/{}/{}, {}",
442+
dt.month(),
443+
dt.day(),
444+
dt.year(),
445+
locale_time_12h(dt.hour(), dt.minute(), dt.second())
446+
)
447+
}
448+
449+
/// `H:MM:SS AM/PM` — `Temporal.PlainTime` default form.
450+
pub fn plain_time_locale_string(t: &PlainTime) -> String {
451+
locale_time_12h(t.hour(), t.minute(), t.second())
452+
}
453+
454+
/// `M/YYYY` — `Temporal.PlainYearMonth` default form (no day component).
455+
pub fn plain_year_month_locale_string(ym: &PlainYearMonth) -> String {
456+
format!("{}/{}", ym.month(), ym.year())
457+
}
458+
459+
/// `M/D` — `Temporal.PlainMonthDay` default form (no year component).
460+
pub fn plain_month_day_locale_string(md: &PlainMonthDay) -> String {
461+
format!("{}/{}", md.month_code().to_month_integer(), md.day())
462+
}
463+
464+
/// `M/D/YYYY, H:MM:SS AM/PM` rendered in the instance's own time zone —
465+
/// `Temporal.ZonedDateTime` default form.
466+
pub fn zoned_date_time_locale_string(z: &ZonedDateTime) -> String {
467+
format!(
468+
"{}/{}/{}, {}",
469+
z.month(),
470+
z.day(),
471+
z.year(),
472+
locale_time_12h(z.hour(), z.minute(), z.second())
473+
)
474+
}
475+
370476
/// `GetOptionsObject`: an options argument must be `undefined` or an Object.
371477
/// Any other value — number, string, boolean, bigint, **symbol** — is a
372478
/// `TypeError`. Methods that take an options bag call this up front so a

crates/perry-runtime/src/temporal/plain_date.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,11 @@ pub fn call(recv: f64, d: &PlainDate, name: &str, args: &[f64]) -> f64 {
223223
"toString" => {
224224
string(&d.to_ixdtf_string(super::options::display_calendar(raw_arg(args, 0))))
225225
}
226-
"toJSON" | "toLocaleString" => string(&d.to_string()),
226+
"toJSON" => string(&d.to_string()),
227+
"toLocaleString" => {
228+
super::options::assert_locale_string_calendar(d.calendar().identifier());
229+
string(&super::options::plain_date_locale_string(d))
230+
}
227231
"valueOf" => dispatch::throw_value_of(TYPE_NAME),
228232
"with" => {
229233
let obj = super::options::require_fields_obj(raw_arg(args, 0), TYPE_NAME, "with");

crates/perry-runtime/src/temporal/plain_date_time.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,11 @@ pub fn call(recv: f64, dt: &PlainDateTime, name: &str, args: &[f64]) -> f64 {
181181
let (rounding, calendar) = super::options::pdt_to_string_options(raw_arg(args, 0));
182182
string(&ok_or_throw(dt.to_ixdtf_string(rounding, calendar)))
183183
}
184-
"toJSON" | "toLocaleString" => string(&dt.to_string()),
184+
"toJSON" => string(&dt.to_string()),
185+
"toLocaleString" => {
186+
super::options::assert_locale_string_calendar(dt.calendar().identifier());
187+
string(&super::options::plain_date_time_locale_string(dt))
188+
}
185189
"valueOf" => dispatch::throw_value_of(TYPE_NAME),
186190
"with" => {
187191
let obj = super::options::require_fields_obj(raw_arg(args, 0), TYPE_NAME, "with");

crates/perry-runtime/src/temporal/plain_month_day.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ pub fn call(recv: f64, md: &PlainMonthDay, name: &str, args: &[f64]) -> f64 {
129129
"toString" => {
130130
string(&md.to_ixdtf_string(super::options::display_calendar(raw_arg(args, 0))))
131131
}
132-
"toJSON" | "toLocaleString" => string(&md.to_string()),
132+
"toJSON" => string(&md.to_string()),
133+
"toLocaleString" => {
134+
super::options::assert_locale_string_calendar(md.calendar().identifier());
135+
string(&super::options::plain_month_day_locale_string(md))
136+
}
133137
"valueOf" => dispatch::throw_value_of(TYPE_NAME),
134138
"with" => {
135139
let obj = super::options::require_fields_obj(raw_arg(args, 0), TYPE_NAME, "with");

crates/perry-runtime/src/temporal/plain_time.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,11 @@ pub fn call(recv: f64, t: &PlainTime, name: &str, args: &[f64]) -> f64 {
190190
"toString" => string(&ok_or_throw(t.to_ixdtf_string(
191191
super::options::to_string_rounding_options(raw_arg(args, 0)),
192192
))),
193-
"toJSON" | "toLocaleString" => string(
193+
"toJSON" => string(
194194
&t.to_ixdtf_string(ToStringRoundingOptions::default())
195195
.unwrap_or_default(),
196196
),
197+
"toLocaleString" => string(&super::options::plain_time_locale_string(t)),
197198
"valueOf" => dispatch::throw_value_of(TYPE_NAME),
198199
"with" => {
199200
let obj = super::options::require_fields_obj(raw_arg(args, 0), TYPE_NAME, "with");

crates/perry-runtime/src/temporal/plain_year_month.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ pub fn call(recv: f64, ym: &PlainYearMonth, name: &str, args: &[f64]) -> f64 {
155155
"toString" => {
156156
string(&ym.to_ixdtf_string(super::options::display_calendar(raw_arg(args, 0))))
157157
}
158-
"toJSON" | "toLocaleString" => string(&ym.to_string()),
158+
"toJSON" => string(&ym.to_string()),
159+
"toLocaleString" => {
160+
super::options::assert_locale_string_calendar(ym.calendar().identifier());
161+
string(&super::options::plain_year_month_locale_string(ym))
162+
}
159163
"valueOf" => dispatch::throw_value_of(TYPE_NAME),
160164
"with" => {
161165
let obj = super::options::require_fields_obj(raw_arg(args, 0), TYPE_NAME, "with");

crates/perry-runtime/src/temporal/zoned_date_time.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,11 @@ pub fn call(recv: f64, z: &ZonedDateTime, name: &str, args: &[f64]) -> f64 {
222222
z.to_ixdtf_string(offset, time_zone, calendar, rounding),
223223
))
224224
}
225-
"toJSON" | "toLocaleString" => string(&z.to_string()),
225+
"toJSON" => string(&z.to_string()),
226+
"toLocaleString" => {
227+
super::options::assert_locale_string_calendar(z.calendar().identifier());
228+
string(&super::options::zoned_date_time_locale_string(z))
229+
}
226230
"valueOf" => dispatch::throw_value_of(TYPE_NAME),
227231
"with" => {
228232
let obj = super::options::require_fields_obj(raw_arg(args, 0), TYPE_NAME, "with");

0 commit comments

Comments
 (0)