Skip to content

Commit f7ae910

Browse files
authored
Do not overly range check when constructing Temporal PlainMonthDay (#695)
1 parent 1acd2c1 commit f7ae910

File tree

5 files changed

+173
-23
lines changed

5 files changed

+173
-23
lines changed

src/builtins/core/calendar.rs

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -350,16 +350,71 @@ impl Calendar {
350350
// For example, constructing a PlainMonthDay for {year: 2025, month: 2, day: 29}
351351
// with overflow: constrain will produce 02-28 since it will constrain
352352
// the date to 2025-02-28 first, and only *then* will it construct an MD.
353-
//
354-
// This is specced partially in https://tc39.es/proposal-temporal/#sec-temporal-calendarmonthdaytoisoreferencedate
355-
// notice that RegulateISODate is called with the passed-in year, but the reference year is used regardless
356-
// of the passed in year in the final result.
357-
//
358-
// There may be more efficient ways to do this, but this works pretty well and doesn't require
359-
// calendrical knowledge.
360353
if fields.year.is_some() || (fields.era.is_some() && fields.era_year.is_some()) {
361-
let date = self.date_from_fields(fields, overflow)?;
362-
fields = CalendarFields::from_date(&date);
354+
// The ISO case is specified in <https://tc39.es/proposal-temporal/#sec-temporal-calendarmonthdaytoisoreferencedate>
355+
// It does not perform any range checks, arbitrarily large year values can be used.
356+
if self.is_iso() {
357+
let resolved = ResolvedIsoFields::try_from_fields(
358+
&fields,
359+
overflow,
360+
ResolutionType::MonthDayWithYear,
361+
)?;
362+
fields = CalendarFields {
363+
year: Some(1972),
364+
month: Some(resolved.month),
365+
day: Some(resolved.day),
366+
..Default::default()
367+
};
368+
} else {
369+
// The non-ISO case is specified in <https://tc39.es/proposal-intl-era-monthcode/#sup-temporal-nonisomonthdaytoisoreferencedate>
370+
//
371+
let date_fields = DateFields::try_from(&fields)?;
372+
{
373+
// This algorithm requires an early-check to ensure the year is *somewhat* valid:
374+
// > b. If there exists no combination of inputs such that ! CalendarIntegersToISO(calendar, fields.[[Year]], ..., ...) would
375+
// return an ISO Date Record isoDate for which ISODateWithinLimits(isoDate) is true, throw a RangeError exception.
376+
377+
// We do this by using constrain with minimal and maximal month-day values to try and
378+
// see if either is in the ISO year range.
379+
//
380+
// This is complicated, it would be nice to not have to do this: <https://github.com/tc39/proposal-intl-era-monthcode/issues/127>
381+
let mut options = DateFromFieldsOptions::default();
382+
options.overflow = Some(IcuOverflow::Constrain);
383+
options.missing_fields_strategy = Some(MissingFieldsStrategy::Reject);
384+
385+
let mut fields_min = fields.clone();
386+
let mut fields_max = fields.clone();
387+
fields_min.month = Some(1);
388+
fields_max.month = Some(15);
389+
fields_min.month_code = None;
390+
fields_max.month_code = None;
391+
fields_min.day = Some(1);
392+
fields_max.day = Some(40);
393+
let fields_min = DateFields::try_from(&fields_min)?;
394+
let fields_max = DateFields::try_from(&fields_max)?;
395+
let date_min = self.0.from_fields(fields_min, options)?;
396+
let date_max = self.0.from_fields(fields_max, options)?;
397+
let iso_min = IsoDate::from_icu4x(self.0.to_iso(&date_min));
398+
let iso_max = IsoDate::from_icu4x(self.0.to_iso(&date_max));
399+
400+
// If *both* are an error, then no date in this year maps to the ISO year range.
401+
if iso_min.check_within_limits().is_err()
402+
&& iso_max.check_within_limits().is_err()
403+
{
404+
return Err(TemporalError::range().with_enum(ErrorMessage::DateOutOfRange));
405+
}
406+
}
407+
let mut options = DateFromFieldsOptions::default();
408+
options.overflow = Some(overflow.into());
409+
options.missing_fields_strategy = Some(MissingFieldsStrategy::Reject);
410+
let calendar_date = self.0.from_fields(date_fields, options)?;
411+
412+
fields = CalendarFields {
413+
month_code: Some(MonthCode(self.0.month(&calendar_date).standard_code.0)),
414+
day: Some(self.0.day_of_month(&calendar_date).0),
415+
..Default::default()
416+
};
417+
}
363418
}
364419
if self.is_iso() {
365420
let resolved_fields =

src/builtins/core/calendar/fields.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,6 @@ impl CalendarFields {
141141
}
142142
}
143143

144-
pub(crate) fn from_date(date: &PlainDate) -> Self {
145-
Self {
146-
year: Some(date.year()),
147-
month: Some(date.month()),
148-
month_code: Some(date.month_code()),
149-
era: date.era().map(TinyAsciiStr::resize),
150-
era_year: date.era_year(),
151-
day: Some(date.day()),
152-
}
153-
}
154-
155144
crate::impl_with_fallback_method!(with_fallback_date, CalendarFields, (with_day: day) PlainDate);
156145
crate::impl_with_fallback_method!(with_fallback_datetime, CalendarFields, (with_day:day) PlainDateTime);
157146
crate::impl_field_keys_to_ignore!((with_day:day));

src/builtins/core/calendar/types.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub enum ResolutionType {
1414
Date,
1515
YearMonth,
1616
MonthDay,
17+
MonthDayWithYear,
1718
}
1819

1920
/// `ResolvedCalendarFields` represents the resolved field values necessary for
@@ -34,7 +35,9 @@ impl ResolvedIsoFields {
3435
overflow: Overflow,
3536
resolve_type: ResolutionType,
3637
) -> TemporalResult<Self> {
37-
fields.check_year_in_safe_arithmetical_range()?;
38+
if resolve_type != ResolutionType::MonthDayWithYear {
39+
fields.check_year_in_safe_arithmetical_range()?;
40+
}
3841
// a. If type is date or year-month and fields.[[Year]] is unset, throw a TypeError exception.
3942
let arithmetic_year = if resolve_type == ResolutionType::MonthDay {
4043
1972

src/builtins/core/plain_month_day.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,4 +646,99 @@ mod tests {
646646
);
647647
}
648648
}
649+
650+
#[test]
651+
fn month_day_range_check_with_year_iso() {
652+
// https://github.com/boa-dev/temporal/issues/688
653+
let iso = IsoDate::new_unchecked(1972, 1, 1);
654+
let md = PlainMonthDay::new_unchecked(iso, Calendar::ISO);
655+
let mut partial = CalendarFields {
656+
year: Some(-271821),
657+
..Default::default()
658+
};
659+
let _ = md.with(partial.clone(), None).expect("should not throw");
660+
661+
partial.year = Some(i32::MIN);
662+
663+
let _ = md.with(partial, None).expect("should not throw");
664+
let fields = CalendarFields {
665+
year: Some(-27182100),
666+
month: Some(2),
667+
day: Some(29),
668+
..Default::default()
669+
};
670+
let mut partial = PartialDate {
671+
calendar_fields: fields,
672+
calendar: Calendar::ISO,
673+
};
674+
let resolved = PlainMonthDay::from_partial(partial.clone(), Some(Overflow::Constrain))
675+
.expect("should not throw");
676+
677+
assert_eq!(
678+
resolved.day(),
679+
28,
680+
"Should use provided year for constraining"
681+
);
682+
683+
partial.calendar_fields.year = Some(-27182400);
684+
let resolved = PlainMonthDay::from_partial(partial.clone(), Some(Overflow::Constrain))
685+
.expect("should not throw");
686+
assert_eq!(
687+
resolved.day(),
688+
29,
689+
"Should use provided year for constraining"
690+
);
691+
}
692+
693+
#[test]
694+
fn month_day_range_check_with_year_non_iso() {
695+
use crate::builtins::calendar::month_to_month_code;
696+
697+
// https://github.com/boa-dev/temporal/issues/688
698+
// Here we need to actually make sure that years get range checked for the calendar,
699+
// since the spec wants us to allow YMD inputs that are out of ISO range, *as long as*
700+
// the year can produce in-ISO-range values for *some* MD values.
701+
702+
let fields = CalendarFields {
703+
year: Some(-27182100),
704+
month_code: Some(month_to_month_code(1).unwrap()),
705+
day: Some(1),
706+
..Default::default()
707+
};
708+
let mut partial = PartialDate {
709+
calendar_fields: fields,
710+
calendar: Calendar::CHINESE,
711+
};
712+
let _ = PlainMonthDay::from_partial(partial.clone(), Some(Overflow::Constrain))
713+
.expect_err("Should not allow far past dates");
714+
PlainDate::from_partial(partial.clone(), Some(Overflow::Constrain))
715+
.expect_err("PlainDate should not be as lenient");
716+
partial.calendar_fields.year = Some(-271821);
717+
718+
let _ = PlainMonthDay::from_partial(partial.clone(), Some(Overflow::Constrain))
719+
.expect("should not throw");
720+
PlainDate::from_partial(partial.clone(), Some(Overflow::Constrain))
721+
.expect_err("PlainDate should not be as lenient");
722+
723+
let fields = CalendarFields {
724+
year: Some(27576000),
725+
month_code: Some(month_to_month_code(12).unwrap()),
726+
day: Some(30),
727+
..Default::default()
728+
};
729+
let mut partial = PartialDate {
730+
calendar_fields: fields,
731+
calendar: Calendar::CHINESE,
732+
};
733+
let _ = PlainMonthDay::from_partial(partial.clone(), Some(Overflow::Constrain))
734+
.expect_err("Should not allow far future dates");
735+
PlainDate::from_partial(partial.clone(), Some(Overflow::Constrain))
736+
.expect_err("PlainDate should not be as lenient");
737+
partial.calendar_fields.year = Some(275760);
738+
739+
let _ = PlainMonthDay::from_partial(partial.clone(), Some(Overflow::Constrain))
740+
.expect("should not throw");
741+
PlainDate::from_partial(partial.clone(), Some(Overflow::Constrain))
742+
.expect_err("PlainDate should not be as lenient");
743+
}
649744
}

src/iso.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ use crate::{
4040
unix_time::EpochNanoseconds,
4141
utils, TemporalResult, TemporalUnwrap, NS_PER_DAY,
4242
};
43-
use icu_calendar::{Date as IcuDate, Iso};
43+
use icu_calendar::{Calendar as IcuCalendar, Date as IcuDate, Iso};
4444
use num_traits::{cast::FromPrimitive, Euclid};
4545

4646
/// `IsoDateTime` is the record of the `IsoDate` and `IsoTime` internal slots.
@@ -295,7 +295,7 @@ impl IsoDate {
295295
/// <https://tc39.es/proposal-temporal/#sec-temporal-isodatetimewithinlimits>
296296
pub fn check_within_limits(self) -> TemporalResult<()> {
297297
if !iso_dt_within_valid_limits(self, &IsoTime::noon()) {
298-
return Err(TemporalError::range().with_message("IsoDate not within a valid range."));
298+
return Err(TemporalError::range().with_enum(ErrorMessage::DateOutOfRange));
299299
}
300300
Ok(())
301301
}
@@ -509,6 +509,14 @@ impl IsoDate {
509509
debug_assert!(d.is_ok(), "ICU4X ISODate conversion must not fail");
510510
d.unwrap_or_else(|_| IcuDate::from_rata_die(icu_calendar::types::RataDie::new(0), Iso))
511511
}
512+
513+
pub(crate) fn from_icu4x(date: <Iso as IcuCalendar>::DateInner) -> Self {
514+
Self::new_unchecked(
515+
Iso.extended_year(&date),
516+
Iso.month(&date).ordinal,
517+
Iso.day_of_month(&date).0,
518+
)
519+
}
512520
}
513521

514522
// ==== `IsoTime` section ====

0 commit comments

Comments
 (0)