From 28d163be2a997961f364a40c14762e80832ea4b5 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 19:13:08 -0800 Subject: [PATCH 01/18] Add generous year range check to from_fields --- .../src/cal/east_asian_traditional.rs | 2 +- components/calendar/src/cal/hijri.rs | 2 +- .../calendar/src/calendar_arithmetic.rs | 54 +++++- components/calendar/src/tests/extrema.rs | 159 +++++++++++++++++- 4 files changed, 204 insertions(+), 13 deletions(-) diff --git a/components/calendar/src/cal/east_asian_traditional.rs b/components/calendar/src/cal/east_asian_traditional.rs index 11f39c2fec5..8047267beeb 100644 --- a/components/calendar/src/cal/east_asian_traditional.rs +++ b/components/calendar/src/cal/east_asian_traditional.rs @@ -599,7 +599,7 @@ impl DateFieldsResolver for EastAsianTraditional { #[inline] fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo { - debug_assert!(crate::calendar_arithmetic::VALID_YEAR_RANGE.contains(&extended_year)); + debug_assert!(crate::calendar_arithmetic::SAFE_YEAR_RANGE.contains(&extended_year)); self.0.year(extended_year) } diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index 3db7f5c23c0..92ad79d7902 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -859,7 +859,7 @@ impl DateFieldsResolver for Hijri { #[inline] fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo { - debug_assert!(crate::calendar_arithmetic::VALID_YEAR_RANGE.contains(&extended_year)); + debug_assert!(crate::calendar_arithmetic::SAFE_YEAR_RANGE.contains(&extended_year)); self.0.year(extended_year) } diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 21de28680f4..d5452651aac 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -22,15 +22,44 @@ use core::ops::RangeInclusive; pub const VALID_YEAR_RANGE: RangeInclusive = -9999..=9999; /// This is a fundamental invariant of `ArithmeticDate` and by extension all our -/// date types. Because this range slightly exceeds the [`VALID_YEAR_RANGE`], only -/// the valid year range is checked in constructors. Only the `Date::from_rata_die` -/// constructor actually uses this, but for clamping instead of erroring. -// This range is the tightest possible range that includes all valid years for all -// calendars, this is asserted in [`test_validity_ranges`]. +/// date types. Because this range exceeds the [`VALID_YEAR_RANGE`], only +/// the valid year range is checked in most constructors. +/// +/// This is the range used by `Date::from_rata_die`, `Date::try_from_fields`, +/// and Date arithmetic operations. pub const VALID_RD_RANGE: RangeInclusive = calendrical_calculations::gregorian::fixed_from_gregorian(-999999, 1, 1) ..=calendrical_calculations::gregorian::fixed_from_gregorian(999999, 12, 31); +/// We *must* ensure dates are within `VALID_RD_RANGE` some point before constructing them. +/// +/// However, we may need to perform a fair amount of calendar arithmetic before +/// getting to the point where we know if we are in range, and the calendar arithmetic +/// is fragile (chance of math issues, slowness, debug assertions) at high ranges. +/// +/// So we try to early-check year values where possible. We use a "generous" year range +/// which is known to be wider than the valid year range for any era in any currently +/// supported calendar. +/// +/// `VALID_RD_RANGE` maps to 1031332 BH..=1030050 AH in the Islamic calendars, which have +/// the shortest years. We pick a slightly wider +/// +/// The tests in `extrema.rs` ensure that all in-range dates can be produced here, +/// and that these year numbers map to out-of-range values for every era. +pub const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_000; + +/// This is like GENEROUS_YEAR_RANGE, but a bit wider to account for era arithmetic. +/// +/// A year that was within the generous year range might get era-adjusted to being +/// outside of it. Instead of performing the range check twice, we expect that +/// our code may experience year values that are outside of but "near" the generous +/// year range, and assert for SAFE_YEAR_RANGE in key spots where using a too-large +/// year value might cause issues further down the line. +/// +/// TLDR: GENEROUS_YEAR_RANGE is for early-checking user inputs. SAFE_YEAR_RANGE is for assertions to +/// ensure that too-large year values are not sneaking in to the code somehow. +pub const SAFE_YEAR_RANGE: RangeInclusive = -1_100_000..=1_100_000; + // Invariant: VALID_RD_RANGE contains the date #[derive(Debug)] pub(crate) struct ArithmeticDate(::Packed); @@ -336,7 +365,7 @@ impl ArithmeticDate { let year = match (fields.era, fields.era_year) { (None, None) => match fields.extended_year { Some(extended_year) => { - if !VALID_YEAR_RANGE.contains(&extended_year) { + if !GENEROUS_YEAR_RANGE.contains(&extended_year) { return Err(DateFromFieldsError::Overflow); } @@ -370,11 +399,11 @@ impl ArithmeticDate { }, }, (Some(era), Some(era_year)) => { - if !VALID_YEAR_RANGE.contains(&era_year) { + if !GENEROUS_YEAR_RANGE.contains(&era_year) { return Err(DateFromFieldsError::Overflow); } let year = calendar.year_info_from_era(era, era_year)?; - if !VALID_YEAR_RANGE.contains(&year.to_extended_year()) { + if !GENEROUS_YEAR_RANGE.contains(&year.to_extended_year()) { return Err(DateFromFieldsError::Overflow); } if let Some(extended_year) = fields.extended_year { @@ -437,7 +466,14 @@ impl ArithmeticDate { } else { return Err(DateFromFieldsError::InvalidDay { max: max_day }); }; - // date is in the valid year range, and therefore in the valid RD range + let rd = C::to_rata_die_inner(year, month, day); + + // We early checked for a generous range of years, now we must check + // to ensure we are actually in range for our core invariant. + if !VALID_RD_RANGE.contains(&rd) { + return Err(DateFromFieldsError::Overflow); + } + // We just checked the RD range above Ok(Self::new_unchecked(year, month, day)) } diff --git a/components/calendar/src/tests/extrema.rs b/components/calendar/src/tests/extrema.rs index fd54a0cae25..2edce53a65e 100644 --- a/components/calendar/src/tests/extrema.rs +++ b/components/calendar/src/tests/extrema.rs @@ -2,8 +2,10 @@ // called LICENSE at the top level of the ICU4X source tree // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). -use crate::calendar_arithmetic::{VALID_RD_RANGE, VALID_YEAR_RANGE}; -use crate::types::Month; +use crate::calendar_arithmetic::{GENEROUS_YEAR_RANGE, VALID_RD_RANGE, VALID_YEAR_RANGE}; +use crate::error::DateFromFieldsError; +use crate::options::{DateFromFieldsOptions, Overflow}; +use crate::types::{DateFields, Month}; use crate::Date; use calendrical_calculations::gregorian::fixed_from_gregorian; use calendrical_calculations::rata_die::RataDie; @@ -51,6 +53,159 @@ super::test_all_cals!( } ); +super::test_all_cals!( + fn check_from_fields_extrema(cal: C) { + let min_date = Date::from_rata_die(*VALID_RD_RANGE.start(), cal); + let max_date = Date::from_rata_die(*VALID_RD_RANGE.end(), cal); + + let first_era = min_date.year().era().map(|e| e.era); + let last_era = max_date.year().era().map(|e| e.era); + + let constrain = DateFromFieldsOptions { + overflow: Some(Overflow::Constrain), + ..Default::default() + }; + let reject = DateFromFieldsOptions { + overflow: Some(Overflow::Reject), + ..Default::default() + }; + + // First we want to test that large values all get range checked + for era in [first_era, last_era, None] { + // We want to ensure that the "early" generous year range check + // AND the + for year in [ + *GENEROUS_YEAR_RANGE.start() - 1, + *GENEROUS_YEAR_RANGE.start(), + *GENEROUS_YEAR_RANGE.start() + 5, + *GENEROUS_YEAR_RANGE.end() + 1, + *GENEROUS_YEAR_RANGE.end(), + *GENEROUS_YEAR_RANGE.end() - 5, + ] { + let mut fields = DateFields { + day: Some(1), + month: Some(Month::new(1)), + ..Default::default() + }; + + if let Some(era) = era.as_ref() { + fields.era_year = Some(year); + fields.era = Some(era.as_bytes()); + } else { + fields.extended_year = Some(year); + } + + let result_constrain = Date::try_from_fields(fields, constrain, cal); + assert_eq!( + result_constrain, + Err(DateFromFieldsError::Overflow), + "{year}-01-01, era {era:?} should fail to construct (constrain)" + ); + + let result_reject = Date::try_from_fields(fields, reject, cal); + assert_eq!( + result_reject, + Err(DateFromFieldsError::Overflow), + "{year}-01-01, era {era:?} should fail to construct (reject)" + ); + } + } + + // Next we want to check that the range check applies exactly at the VALID_RD_RANGE + // border. + + let min_day = min_date.day_of_month().0; + let min_month = min_date.month().ordinal; + let min_year = min_date.year().extended_year(); + + // Check that the lowest date roundtrips + let min_fields = DateFields { + day: Some(min_day), + ordinal_month: Some(min_month), + extended_year: Some(min_year), + ..Default::default() + }; + let min_constrain = Date::try_from_fields(min_fields, constrain, cal); + assert_eq!( + min_constrain, + Ok(min_date), + "Min date {min_date:?} should roundtrip via {min_fields:?}" + ); + + // Then check that the date before that does not. + let min_minus_one = if min_day > 1 { + DateFields { + day: Some(min_day - 1), + ..min_fields + } + } else if min_month > 1 { + DateFields { + day: Some(50), // Should constrain + ordinal_month: Some(min_month - 1), + ..min_fields + } + } else { + DateFields { + day: Some(50), // Should constrain + ordinal_month: Some(50), + extended_year: Some(min_year - 1), + ..Default::default() + } + }; + let min_minus_one_constrain = Date::try_from_fields(min_minus_one, constrain, cal); + assert_eq!( + min_minus_one_constrain, + Err(DateFromFieldsError::Overflow), + "Min date {min_date:?} minus one should fail to construct via {min_minus_one_constrain:?}" + ); + + let max_day = max_date.day_of_month().0; + let max_month = max_date.month().ordinal; + let max_year = max_date.year().extended_year(); + + // Check that the highest date roundtrips + let max_fields = DateFields { + day: Some(max_day), + ordinal_month: Some(max_month), + extended_year: Some(max_year), + ..Default::default() + }; + let max_constrain = Date::try_from_fields(max_fields, constrain, cal); + assert_eq!( + max_constrain, + Ok(max_date), + "Max date {max_date:?} should roundtrip via {max_fields:?}" + ); + + // Then check that the date after that does not. + let max_plus_one = if max_day < min_date.days_in_month() { + DateFields { + day: Some(max_day + 1), + ..max_fields + } + } else if max_month < min_date.months_in_year() { + DateFields { + day: Some(1), + ordinal_month: Some(max_month + 1), + ..max_fields + } + } else { + DateFields { + day: Some(1), + ordinal_month: Some(1), + extended_year: Some(max_year + 1), + ..Default::default() + } + }; + let max_plus_one_constrain = Date::try_from_fields(max_plus_one, constrain, cal); + assert_eq!( + max_plus_one_constrain, + Err(DateFromFieldsError::Overflow), + "Min date {max_date:?} minus one should fail to construct via {max_plus_one_constrain:?}" + ); + } +); + super::test_all_cals!( fn check_from_codes_extrema(cal: C) { // Success From 9684e2e956d5ae14fd1013e74eed12d3a6c6d14f Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 20:42:03 -0800 Subject: [PATCH 02/18] Replace year_info_from_era with extended_year_from_era_year --- .../calendar/src/cal/abstract_gregorian.rs | 6 ++-- components/calendar/src/cal/coptic.rs | 4 +-- .../src/cal/east_asian_traditional.rs | 4 +-- components/calendar/src/cal/ethiopian.rs | 11 ++++--- components/calendar/src/cal/hebrew.rs | 6 ++-- components/calendar/src/cal/hijri.rs | 6 ++-- components/calendar/src/cal/indian.rs | 4 +-- components/calendar/src/cal/julian.rs | 4 +-- components/calendar/src/cal/persian.rs | 4 +-- .../calendar/src/calendar_arithmetic.rs | 29 +++++++++++-------- 10 files changed, 43 insertions(+), 35 deletions(-) diff --git a/components/calendar/src/cal/abstract_gregorian.rs b/components/calendar/src/cal/abstract_gregorian.rs index e6670ad178e..d3920be6b92 100644 --- a/components/calendar/src/cal/abstract_gregorian.rs +++ b/components/calendar/src/cal/abstract_gregorian.rs @@ -50,12 +50,12 @@ impl DateFieldsResolver for AbstractGregorian { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { - Ok(self.0.extended_from_era_year(Some(era), era_year)? + Y::EXTENDED_YEAR_OFFSET) + ) -> Result { + self.0.extended_from_era_year(Some(era), era_year) } #[inline] diff --git a/components/calendar/src/cal/coptic.rs b/components/calendar/src/cal/coptic.rs index c818a69abc1..6ec54d7409c 100644 --- a/components/calendar/src/cal/coptic.rs +++ b/components/calendar/src/cal/coptic.rs @@ -66,11 +66,11 @@ impl DateFieldsResolver for Coptic { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { match era { b"am" => Ok(era_year), _ => Err(UnknownEraError), diff --git a/components/calendar/src/cal/east_asian_traditional.rs b/components/calendar/src/cal/east_asian_traditional.rs index 8047267beeb..3a55b0d98c6 100644 --- a/components/calendar/src/cal/east_asian_traditional.rs +++ b/components/calendar/src/cal/east_asian_traditional.rs @@ -588,11 +588,11 @@ impl DateFieldsResolver for EastAsianTraditional { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, _era: &[u8], _era_year: i32, - ) -> Result { + ) -> Result { // This calendar has no era codes Err(UnknownEraError) } diff --git a/components/calendar/src/cal/ethiopian.rs b/components/calendar/src/cal/ethiopian.rs index 8de9b25ae30..633c600e9b7 100644 --- a/components/calendar/src/cal/ethiopian.rs +++ b/components/calendar/src/cal/ethiopian.rs @@ -93,14 +93,17 @@ impl DateFieldsResolver for Ethiopian { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { match (self.era_style(), era) { - (EthiopianEraStyle::AmeteMihret, b"am") => Ok(era_year + AMETE_MIHRET_OFFSET), - (_, b"aa") => Ok(era_year + AMETE_ALEM_OFFSET), + (EthiopianEraStyle::AmeteMihret, b"am") => Ok(era_year), + (EthiopianEraStyle::AmeteMihret, b"aa") => { + Ok(era_year - AMETE_MIHRET_OFFSET + AMETE_ALEM_OFFSET) + } + (EthiopianEraStyle::AmeteAlem, b"aa") => Ok(era_year), (_, _) => Err(UnknownEraError), } } diff --git a/components/calendar/src/cal/hebrew.rs b/components/calendar/src/cal/hebrew.rs index ae1217a9bea..52581bb6f4c 100644 --- a/components/calendar/src/cal/hebrew.rs +++ b/components/calendar/src/cal/hebrew.rs @@ -135,13 +135,13 @@ impl DateFieldsResolver for Hebrew { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { match era { - b"am" => Ok(HebrewYear::compute(era_year)), + b"am" => Ok(era_year), _ => Err(UnknownEraError), } } diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index 92ad79d7902..9a194ab8862 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -844,17 +844,17 @@ impl DateFieldsResolver for Hijri { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { let extended_year = match era { b"ah" => era_year, b"bh" => 1 - era_year, _ => return Err(UnknownEraError), }; - Ok(self.year_info_from_extended(extended_year)) + Ok(extended_year) } #[inline] diff --git a/components/calendar/src/cal/indian.rs b/components/calendar/src/cal/indian.rs index a369d586974..6ff1717dfac 100644 --- a/components/calendar/src/cal/indian.rs +++ b/components/calendar/src/cal/indian.rs @@ -69,11 +69,11 @@ impl DateFieldsResolver for Indian { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { match era { b"shaka" => Ok(era_year), _ => Err(UnknownEraError), diff --git a/components/calendar/src/cal/julian.rs b/components/calendar/src/cal/julian.rs index 02793dedc75..6efa9e25c8a 100644 --- a/components/calendar/src/cal/julian.rs +++ b/components/calendar/src/cal/julian.rs @@ -91,11 +91,11 @@ impl DateFieldsResolver for Julian { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { match era { b"ad" | b"ce" => Ok(era_year), b"bc" | b"bce" => Ok(1 - era_year), diff --git a/components/calendar/src/cal/persian.rs b/components/calendar/src/cal/persian.rs index 19ed1ca024b..d401167979e 100644 --- a/components/calendar/src/cal/persian.rs +++ b/components/calendar/src/cal/persian.rs @@ -63,11 +63,11 @@ impl DateFieldsResolver for Persian { } #[inline] - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result { + ) -> Result { match era { b"ap" => Ok(era_year), _ => Err(UnknownEraError), diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index d5452651aac..a0b6c0d5d39 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -168,11 +168,11 @@ pub(crate) trait DateFieldsResolver: Calendar { /// Converts the era and era year to a [`Self::YearInfo`]. If the calendar does not have eras, /// this should always return an Err result. - fn year_info_from_era( + fn extended_year_from_era_year( &self, era: &[u8], era_year: i32, - ) -> Result; + ) -> Result; /// Converts an extended year to a [`Self::YearInfo`]. fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo; @@ -266,14 +266,17 @@ impl ArithmeticDate { day: u8, calendar: &C, ) -> Result { - let year = if let Some(era) = era { - let year = calendar - .year_info_from_era(era.as_bytes(), range_check(year, "year", VALID_YEAR_RANGE)?)?; - range_check(year.to_extended_year(), "era_year", VALID_YEAR_RANGE)?; + let extended_year = if let Some(era) = era { + let year = calendar.extended_year_from_era_year( + era.as_bytes(), + range_check(year, "year", VALID_YEAR_RANGE)?, + )?; + range_check(year, "era_year", VALID_YEAR_RANGE)?; year } else { - calendar.year_info_from_extended(range_check(year, "extended_year", VALID_YEAR_RANGE)?) + range_check(year, "extended_year", VALID_YEAR_RANGE)? }; + let year = calendar.year_info_from_extended(extended_year); let validated = Month::try_from_utf8(month_code.0.as_bytes()).map_err(|e| match e { MonthCodeParseError::InvalidSyntax => DateError::UnknownMonthCode(month_code), })?; @@ -354,7 +357,7 @@ impl ArithmeticDate { let mut valid_month = None; // NOTE: The year/extendedyear range check is important to avoid arithmetic - // overflow in `year_info_from_era` and `year_info_from_extended`. It + // overflow in `extended_year_from_era_year` and `year_info_from_extended`. It // must happen before they are called. // TODO: update to a wider year range that allows for the full RD range to constructed // @@ -402,10 +405,11 @@ impl ArithmeticDate { if !GENEROUS_YEAR_RANGE.contains(&era_year) { return Err(DateFromFieldsError::Overflow); } - let year = calendar.year_info_from_era(era, era_year)?; - if !GENEROUS_YEAR_RANGE.contains(&year.to_extended_year()) { + let extended_year = calendar.extended_year_from_era_year(era, era_year)?; + if !GENEROUS_YEAR_RANGE.contains(&extended_year) { return Err(DateFromFieldsError::Overflow); } + let year = calendar.year_info_from_extended(extended_year); if let Some(extended_year) = fields.extended_year { if year.to_extended_year() != extended_year { return Err(DateFromFieldsError::InconsistentYear); @@ -506,17 +510,18 @@ impl ArithmeticDate { day: u8, cal: &C, ) -> Result { - let year_info = cal.year_info_from_era( + let extended_year = cal.extended_year_from_era_year( era.as_bytes(), range_check(year, "era_year", VALID_YEAR_RANGE)?, )?; // check the extended year in terms of the year - let offset = year - year_info.to_extended_year(); + let offset = year - extended_year; range_check( year, // == year_info.to_extended_year() + offset "extended_year", (VALID_YEAR_RANGE.start() + offset)..=(VALID_YEAR_RANGE.end() + offset), )?; + let year_info = cal.year_info_from_extended(extended_year); range_check(month, "month", 1..=C::months_in_provided_year(year_info))?; range_check(day, "day", 1..=C::days_in_provided_month(year_info, month))?; // date is in the valid year range, and therefore in the valid RD range From d509ec45db0c26bdbcbc5fb90d6693bbc9e53f8d Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 20:35:52 -0800 Subject: [PATCH 03/18] Introduce year_info_from_extended_checked for use in overflow-checked cases --- .../src/cal/east_asian_traditional.rs | 2 +- components/calendar/src/cal/hijri.rs | 2 +- .../calendar/src/calendar_arithmetic.rs | 33 ++++++++----------- components/calendar/src/error.rs | 10 ++++++ 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/components/calendar/src/cal/east_asian_traditional.rs b/components/calendar/src/cal/east_asian_traditional.rs index 3a55b0d98c6..40d9b0df45b 100644 --- a/components/calendar/src/cal/east_asian_traditional.rs +++ b/components/calendar/src/cal/east_asian_traditional.rs @@ -599,7 +599,7 @@ impl DateFieldsResolver for EastAsianTraditional { #[inline] fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo { - debug_assert!(crate::calendar_arithmetic::SAFE_YEAR_RANGE.contains(&extended_year)); + debug_assert!(crate::calendar_arithmetic::GENEROUS_YEAR_RANGE.contains(&extended_year)); self.0.year(extended_year) } diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index 9a194ab8862..597ee9279fd 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -859,7 +859,7 @@ impl DateFieldsResolver for Hijri { #[inline] fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo { - debug_assert!(crate::calendar_arithmetic::SAFE_YEAR_RANGE.contains(&extended_year)); + debug_assert!(crate::calendar_arithmetic::GENEROUS_YEAR_RANGE.contains(&extended_year)); self.0.year(extended_year) } diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index a0b6c0d5d39..8bdc5f4d700 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -7,7 +7,7 @@ use calendrical_calculations::rata_die::RataDie; use crate::duration::{DateDuration, DateDurationUnit}; use crate::error::{ range_check, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, - MonthCodeParseError, MonthError, UnknownEraError, + MonthCodeParseError, MonthError, UnknownEraError, YearOverflowError, }; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, MissingFieldsStrategy, Overflow}; @@ -48,18 +48,6 @@ pub const VALID_RD_RANGE: RangeInclusive = /// and that these year numbers map to out-of-range values for every era. pub const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_000; -/// This is like GENEROUS_YEAR_RANGE, but a bit wider to account for era arithmetic. -/// -/// A year that was within the generous year range might get era-adjusted to being -/// outside of it. Instead of performing the range check twice, we expect that -/// our code may experience year values that are outside of but "near" the generous -/// year range, and assert for SAFE_YEAR_RANGE in key spots where using a too-large -/// year value might cause issues further down the line. -/// -/// TLDR: GENEROUS_YEAR_RANGE is for early-checking user inputs. SAFE_YEAR_RANGE is for assertions to -/// ensure that too-large year values are not sneaking in to the code somehow. -pub const SAFE_YEAR_RANGE: RangeInclusive = -1_100_000..=1_100_000; - // Invariant: VALID_RD_RANGE contains the date #[derive(Debug)] pub(crate) struct ArithmeticDate(::Packed); @@ -177,6 +165,16 @@ pub(crate) trait DateFieldsResolver: Calendar { /// Converts an extended year to a [`Self::YearInfo`]. fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo; + fn year_info_from_extended_checked( + &self, + extended_year: i32, + ) -> Result { + if !GENEROUS_YEAR_RANGE.contains(&extended_year) { + return Err(YearOverflowError); + } + Ok(self.year_info_from_extended(extended_year)) + } + /// Calculates the ECMA reference year (represented as an extended year) /// for the month code and day, or an error if the month code and day are invalid. /// @@ -356,11 +354,6 @@ impl ArithmeticDate { let mut valid_month = None; - // NOTE: The year/extendedyear range check is important to avoid arithmetic - // overflow in `extended_year_from_era_year` and `year_info_from_extended`. It - // must happen before they are called. - // TODO: update to a wider year range that allows for the full RD range to constructed - // // To better match the Temporal specification's order of operations, we try // to return structural type errors (`NotEnoughFields`) before checking for range errors. // This isn't behavior we *must* have, but it is not much additional work to maintain @@ -372,7 +365,7 @@ impl ArithmeticDate { return Err(DateFromFieldsError::Overflow); } - calendar.year_info_from_extended(extended_year) + calendar.year_info_from_extended_checked(extended_year)? } None => match missing_fields_strategy { MissingFieldsStrategy::Reject => { @@ -409,7 +402,7 @@ impl ArithmeticDate { if !GENEROUS_YEAR_RANGE.contains(&extended_year) { return Err(DateFromFieldsError::Overflow); } - let year = calendar.year_info_from_extended(extended_year); + let year = calendar.year_info_from_extended_checked(extended_year)?; if let Some(extended_year) = fields.extended_year { if year.to_extended_year() != extended_year { return Err(DateFromFieldsError::InconsistentYear); diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index 4641839e133..93e873772c4 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -480,6 +480,16 @@ impl From for DateFromFieldsError { } } +/// The error returned by `year_info_from_extended_checked` when +/// the extended year is outside of `GENEROUS_YEAR_RANGE`. +pub(crate) struct YearOverflowError; + +impl From for DateFromFieldsError { + fn from(_other: YearOverflowError) -> Self { + DateFromFieldsError::Overflow + } +} + /// Error for [`Month`](crate::types::Month) parsing #[derive(Debug)] #[non_exhaustive] From a772a33bf7d565d3e382eb2d1d19127d6e84f0cc Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 21:01:31 -0800 Subject: [PATCH 04/18] Add and use DateAddError --- components/calendar/src/any_calendar.rs | 2 +- .../calendar/src/cal/abstract_gregorian.rs | 8 ++- components/calendar/src/cal/buddhist.rs | 2 +- components/calendar/src/cal/coptic.rs | 6 +- .../src/cal/east_asian_traditional.rs | 6 +- components/calendar/src/cal/ethiopian.rs | 6 +- components/calendar/src/cal/gregorian.rs | 2 +- components/calendar/src/cal/hebrew.rs | 6 +- components/calendar/src/cal/hijri.rs | 6 +- components/calendar/src/cal/indian.rs | 6 +- components/calendar/src/cal/iso.rs | 2 +- components/calendar/src/cal/julian.rs | 6 +- components/calendar/src/cal/persian.rs | 6 +- components/calendar/src/cal/roc.rs | 2 +- components/calendar/src/calendar.rs | 4 +- .../calendar/src/calendar_arithmetic.rs | 41 +++++-------- components/calendar/src/date.rs | 6 +- components/calendar/src/error.rs | 58 ++++++++++++++++++- ffi/capi/tests/missing_apis.txt | 1 + 19 files changed, 117 insertions(+), 59 deletions(-) diff --git a/components/calendar/src/any_calendar.rs b/components/calendar/src/any_calendar.rs index 9f69c1e49fa..bb081b20077 100644 --- a/components/calendar/src/any_calendar.rs +++ b/components/calendar/src/any_calendar.rs @@ -325,7 +325,7 @@ macro_rules! make_any_calendar { date: &Self::DateInner, duration: $crate::types::DateDuration, options: $crate::options::DateAddOptions, - ) -> Result { + ) -> Result { let mut date = *date; match (self, &mut date) { $( diff --git a/components/calendar/src/cal/abstract_gregorian.rs b/components/calendar/src/cal/abstract_gregorian.rs index d3920be6b92..41b7befc361 100644 --- a/components/calendar/src/cal/abstract_gregorian.rs +++ b/components/calendar/src/cal/abstract_gregorian.rs @@ -4,7 +4,9 @@ use crate::cal::iso::{IsoDateInner, IsoEra}; use crate::calendar_arithmetic::{ArithmeticDate, DateFieldsResolver}; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::preferences::CalendarAlgorithm; @@ -147,7 +149,7 @@ impl Calendar for AbstractGregorian { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.added(duration, &AbstractGregorian(IsoEra), options) } @@ -289,7 +291,7 @@ macro_rules! impl_with_abstract_gregorian { date: &Self::DateInner, duration: crate::types::DateDuration, options: crate::options::DateAddOptions, - ) -> Result { + ) -> Result { let $self_ident = self; crate::cal::abstract_gregorian::AbstractGregorian($eras_expr) .add(&date.0, duration, options) diff --git a/components/calendar/src/cal/buddhist.rs b/components/calendar/src/cal/buddhist.rs index cb87bf8e352..cc54c03c3a8 100644 --- a/components/calendar/src/cal/buddhist.rs +++ b/components/calendar/src/cal/buddhist.rs @@ -8,7 +8,7 @@ use crate::preferences::CalendarAlgorithm; use crate::{ cal::abstract_gregorian::{impl_with_abstract_gregorian, GregorianYears}, calendar_arithmetic::ArithmeticDate, - types, Date, DateError, RangeError, + types, Date, RangeError, }; use tinystr::tinystr; diff --git a/components/calendar/src/cal/coptic.rs b/components/calendar/src/cal/coptic.rs index 6ec54d7409c..6e501da28a4 100644 --- a/components/calendar/src/cal/coptic.rs +++ b/components/calendar/src/cal/coptic.rs @@ -4,7 +4,9 @@ use crate::calendar_arithmetic::ArithmeticDate; use crate::calendar_arithmetic::DateFieldsResolver; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::{types, Calendar, Date, RangeError}; @@ -184,7 +186,7 @@ impl Calendar for Coptic { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(CopticDateInner) } diff --git a/components/calendar/src/cal/east_asian_traditional.rs b/components/calendar/src/cal/east_asian_traditional.rs index 40d9b0df45b..73e5b8bad5e 100644 --- a/components/calendar/src/cal/east_asian_traditional.rs +++ b/components/calendar/src/cal/east_asian_traditional.rs @@ -5,8 +5,8 @@ use crate::calendar_arithmetic::{ArithmeticDate, ToExtendedYear}; use crate::calendar_arithmetic::{DateFieldsResolver, PackWithMD}; use crate::error::{ - DateError, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, MonthError, - UnknownEraError, + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, + MonthError, UnknownEraError, }; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, Overflow}; @@ -724,7 +724,7 @@ impl Calendar for EastAsianTraditional { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(ChineseDateInner) } diff --git a/components/calendar/src/cal/ethiopian.rs b/components/calendar/src/cal/ethiopian.rs index 633c600e9b7..edcb819a720 100644 --- a/components/calendar/src/cal/ethiopian.rs +++ b/components/calendar/src/cal/ethiopian.rs @@ -5,7 +5,9 @@ use crate::cal::coptic::CopticDateInner; use crate::cal::Coptic; use crate::calendar_arithmetic::{ArithmeticDate, DateFieldsResolver}; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::types::DateFields; @@ -193,7 +195,7 @@ impl Calendar for Ethiopian { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { Coptic .add(&date.0, duration, options) .map(EthiopianDateInner) diff --git a/components/calendar/src/cal/gregorian.rs b/components/calendar/src/cal/gregorian.rs index 1aa4d80d8fd..5e4bb780c1b 100644 --- a/components/calendar/src/cal/gregorian.rs +++ b/components/calendar/src/cal/gregorian.rs @@ -8,7 +8,7 @@ use crate::cal::abstract_gregorian::{ use crate::calendar_arithmetic::ArithmeticDate; use crate::error::UnknownEraError; use crate::preferences::CalendarAlgorithm; -use crate::{types, Date, DateError, RangeError}; +use crate::{types, Date, RangeError}; use tinystr::tinystr; impl_with_abstract_gregorian!(Gregorian, GregorianDateInner, CeBce, _x, CeBce); diff --git a/components/calendar/src/cal/hebrew.rs b/components/calendar/src/cal/hebrew.rs index 52581bb6f4c..1dd2cc3f25a 100644 --- a/components/calendar/src/cal/hebrew.rs +++ b/components/calendar/src/cal/hebrew.rs @@ -4,8 +4,8 @@ use crate::calendar_arithmetic::{ArithmeticDate, DateFieldsResolver, PackWithMD, ToExtendedYear}; use crate::error::{ - DateError, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, MonthError, - UnknownEraError, + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, + MonthError, UnknownEraError, }; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, Overflow}; @@ -290,7 +290,7 @@ impl Calendar for Hebrew { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(HebrewDateInner) } diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index 597ee9279fd..8010e805c9a 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -6,7 +6,9 @@ use crate::calendar_arithmetic::ArithmeticDate; use crate::calendar_arithmetic::DateFieldsResolver; use crate::calendar_arithmetic::PackWithMD; use crate::calendar_arithmetic::ToExtendedYear; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::types::DateFields; @@ -940,7 +942,7 @@ impl Calendar for Hijri { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(HijriDateInner) } diff --git a/components/calendar/src/cal/indian.rs b/components/calendar/src/cal/indian.rs index 6ff1717dfac..89515855922 100644 --- a/components/calendar/src/cal/indian.rs +++ b/components/calendar/src/cal/indian.rs @@ -4,7 +4,9 @@ use crate::calendar_arithmetic::ArithmeticDate; use crate::calendar_arithmetic::DateFieldsResolver; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::types::DateFields; @@ -223,7 +225,7 @@ impl Calendar for Indian { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(IndianDateInner) } diff --git a/components/calendar/src/cal/iso.rs b/components/calendar/src/cal/iso.rs index b480e96474d..0f8a36b5cb1 100644 --- a/components/calendar/src/cal/iso.rs +++ b/components/calendar/src/cal/iso.rs @@ -7,7 +7,7 @@ use crate::cal::abstract_gregorian::{ }; use crate::calendar_arithmetic::ArithmeticDate; use crate::error::UnknownEraError; -use crate::{types, Date, DateError, RangeError}; +use crate::{types, Date, RangeError}; use tinystr::tinystr; /// The [ISO-8601 Calendar](https://en.wikipedia.org/wiki/ISO_8601#Dates) diff --git a/components/calendar/src/cal/julian.rs b/components/calendar/src/cal/julian.rs index 6efa9e25c8a..52f6d2fa88d 100644 --- a/components/calendar/src/cal/julian.rs +++ b/components/calendar/src/cal/julian.rs @@ -4,7 +4,9 @@ use crate::calendar_arithmetic::ArithmeticDate; use crate::calendar_arithmetic::DateFieldsResolver; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::types::DateFields; @@ -193,7 +195,7 @@ impl Calendar for Julian { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(JulianDateInner) } diff --git a/components/calendar/src/cal/persian.rs b/components/calendar/src/cal/persian.rs index d401167979e..f85269905f2 100644 --- a/components/calendar/src/cal/persian.rs +++ b/components/calendar/src/cal/persian.rs @@ -4,7 +4,9 @@ use crate::calendar_arithmetic::ArithmeticDate; use crate::calendar_arithmetic::DateFieldsResolver; -use crate::error::{DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError}; +use crate::error::{ + DateAddError, DateError, DateFromFieldsError, EcmaReferenceYearError, UnknownEraError, +}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::types::DateFields; @@ -168,7 +170,7 @@ impl Calendar for Persian { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { date.0.added(duration, self, options).map(PersianDateInner) } diff --git a/components/calendar/src/cal/roc.rs b/components/calendar/src/cal/roc.rs index 3eb84be704a..d0cf048c10f 100644 --- a/components/calendar/src/cal/roc.rs +++ b/components/calendar/src/cal/roc.rs @@ -8,7 +8,7 @@ use crate::cal::abstract_gregorian::{ use crate::calendar_arithmetic::ArithmeticDate; use crate::error::UnknownEraError; use crate::preferences::CalendarAlgorithm; -use crate::{types, Date, DateError, RangeError}; +use crate::{types, Date, RangeError}; use tinystr::tinystr; /// The [Republic of China Calendar](https://en.wikipedia.org/wiki/Republic_of_China_calendar) diff --git a/components/calendar/src/calendar.rs b/components/calendar/src/calendar.rs index d98834a7581..ac9b8ab5158 100644 --- a/components/calendar/src/calendar.rs +++ b/components/calendar/src/calendar.rs @@ -5,7 +5,7 @@ use calendrical_calculations::rata_die::RataDie; use crate::cal::iso::IsoDateInner; -use crate::error::{DateError, DateFromFieldsError}; +use crate::error::{DateAddError, DateError, DateFromFieldsError}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::{types, Iso}; @@ -138,7 +138,7 @@ pub trait Calendar: crate::cal::scaffold::UnstableSealed { date: &Self::DateInner, duration: types::DateDuration, options: DateAddOptions, - ) -> Result; + ) -> Result; /// Calculate `date2 - date` as a duration /// diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 8bdc5f4d700..5aef72015e9 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -6,7 +6,7 @@ use calendrical_calculations::rata_die::RataDie; use crate::duration::{DateDuration, DateDurationUnit}; use crate::error::{ - range_check, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, + range_check, DateAddError, DateFromFieldsError, EcmaReferenceYearError, LunisolarDateError, MonthCodeParseError, MonthError, UnknownEraError, YearOverflowError, }; use crate::options::{DateAddOptions, DateDifferenceOptions}; @@ -165,6 +165,11 @@ pub(crate) trait DateFieldsResolver: Calendar { /// Converts an extended year to a [`Self::YearInfo`]. fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo; + /// `year_info_from_extended` will debug assert if given a too-large year + /// value. Most constructors range check for much smaller ranges, + /// but operations that only enforce the `VALID_RD_RANGE` should + /// be careful what they feed to it. They can use this checked version + /// instead. fn year_info_from_extended_checked( &self, extended_year: i32, @@ -304,12 +309,7 @@ impl ArithmeticDate { } let year = calendar.year_info_from_extended(year); - let month = calendar - .ordinal_from_month(year, month, Default::default()) - .map_err(|e| match e { - MonthError::NotInCalendar => LunisolarDateError::MonthNotInCalendar, - MonthError::NotInYear => LunisolarDateError::MonthNotInYear, - })?; + let month = calendar.ordinal_from_month(year, month, Default::default())?; let max_day = C::days_in_provided_month(year, month); if !(1..=max_day).contains(&day) { @@ -784,25 +784,17 @@ impl ArithmeticDate { duration: DateDuration, cal: &C, options: DateAddOptions, - ) -> Result { + ) -> Result { // 1. Let _parts_ be CalendarISOToDate(_calendar_, _isoDate_). // 1. Let _y0_ be _parts_.[[Year]] + _duration_.[[Years]]. let y0 = cal.year_info_from_extended(duration.add_years_to(self.year().to_extended_year())); // 1. Let _m0_ be MonthCodeToOrdinal(_calendar_, _y0_, ! ConstrainMonthCode(_calendar_, _y0_, _parts_.[[MonthCode]], _overflow_)). let base_month = cal.month_from_ordinal(self.year(), self.month()); - let m0 = cal - .ordinal_from_month( - y0, - base_month, - DateFromFieldsOptions::from_add_options(options), - ) - .map_err(|e| { - // TODO: Use a narrower error type here. For now, convert into DateError. - match e { - MonthError::NotInCalendar => DateError::UnknownMonthCode(base_month.code()), - MonthError::NotInYear => DateError::UnknownMonthCode(base_month.code()), - } - })?; + let m0 = cal.ordinal_from_month( + y0, + base_month, + DateFromFieldsOptions::from_add_options(options), + )?; // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _duration_.[[Months]] + 1, 0). let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal); // 1. Let _baseDay_ be _parts_.[[Day]]. @@ -816,11 +808,8 @@ impl ArithmeticDate { // 1. If _overflow_ is ~reject~, throw a *RangeError* exception. // Note: ICU4X default is constrain here if matches!(options.overflow, Some(Overflow::Reject)) { - return Err(DateError::Range { - field: "day", - value: i32::from(base_day), - min: 1, - max: i32::from(end_of_month.day()), + return Err(DateAddError::InvalidDay { + max: end_of_month.day(), }); } end_of_month.day() diff --git a/components/calendar/src/date.rs b/components/calendar/src/date.rs index d260cc75e3c..4debb577ada 100644 --- a/components/calendar/src/date.rs +++ b/components/calendar/src/date.rs @@ -3,7 +3,7 @@ // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). use crate::calendar_arithmetic::VALID_RD_RANGE; -use crate::error::{DateError, DateFromFieldsError}; +use crate::error::{DateAddError, DateError, DateFromFieldsError}; use crate::options::DateFromFieldsOptions; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::types::{CyclicYear, EraYear, IsoWeekOfYear}; @@ -333,7 +333,7 @@ impl Date { &mut self, duration: types::DateDuration, options: DateAddOptions, - ) -> Result<(), DateError> { + ) -> Result<(), DateAddError> { let inner = self .calendar .as_calendar() @@ -358,7 +358,7 @@ impl Date { mut self, duration: types::DateDuration, options: DateAddOptions, - ) -> Result { + ) -> Result { self.try_add_with_options(duration, options)?; Ok(self) } diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index 93e873772c4..ce3266b47b7 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -143,9 +143,9 @@ pub enum LunisolarDateError { impl core::error::Error for LunisolarDateError {} #[cfg(feature = "unstable")] -pub use unstable::DateFromFieldsError; +pub use unstable::{DateAddError, DateFromFieldsError}; #[cfg(not(feature = "unstable"))] -pub(crate) use unstable::DateFromFieldsError; +pub(crate) use unstable::{DateAddError, DateFromFieldsError}; mod unstable { pub use super::*; @@ -461,6 +461,40 @@ mod unstable { } impl core::error::Error for DateFromFieldsError {} + + /// Error type for date addition via [`Date::try_add_with_options`]. + /// + /// [`Date::try_add_with_options`]: crate::Date::try_add_with_options + /// + ///
+ /// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways, + /// including in SemVer minor releases. Do not use this type unless you are prepared for things to occasionally break. + /// + /// Graduation tracking issue: [issue #7161](https://github.com/unicode-org/icu4x/issues/7161). + ///
+ /// + /// ✨ *Enabled with the `unstable` Cargo feature.* + #[derive(Debug, Copy, Clone, PartialEq, Display)] + #[non_exhaustive] + pub enum DateAddError { + /// The day is invalid for the given month. + #[displaydoc("Invalid day for month, max is {max}")] + InvalidDay { + /// The maximum allowed value (the minimum is 1). + max: u8, + }, + /// The specified month does not exist in this calendar. + #[displaydoc("The specified month does not exist in this calendar")] + MonthNotInCalendar, + /// The specified month exists in this calendar, but not in the specified year. + #[displaydoc("The specified month exists in this calendar, but not for this year")] + MonthNotInYear, + /// The date is out of range. + #[displaydoc("Result out of range")] + Overflow, + } + + impl core::error::Error for DateAddError {} } /// Internal narrow error type for functions that only fail on unknown eras @@ -524,6 +558,26 @@ impl From for DateFromFieldsError { } } +impl From for DateAddError { + #[inline] + fn from(value: MonthError) -> Self { + match value { + MonthError::NotInCalendar => DateAddError::MonthNotInCalendar, + MonthError::NotInYear => DateAddError::MonthNotInYear, + } + } +} + +impl From for LunisolarDateError { + #[inline] + fn from(value: MonthError) -> Self { + match value { + MonthError::NotInCalendar => LunisolarDateError::MonthNotInCalendar, + MonthError::NotInYear => LunisolarDateError::MonthNotInYear, + } + } +} + mod inner { /// Internal narrow error type for calculating the ECMA reference year /// diff --git a/ffi/capi/tests/missing_apis.txt b/ffi/capi/tests/missing_apis.txt index adad73e35b4..7acbb45d04b 100644 --- a/ffi/capi/tests/missing_apis.txt +++ b/ffi/capi/tests/missing_apis.txt @@ -17,6 +17,7 @@ icu::calendar::Date::try_add_with_options#FnInStruct icu::calendar::Date::try_added_with_options#FnInStruct icu::calendar::Date::try_until_with_options#FnInStruct ++icu::calendar::error::DateAddError#Enum icu::calendar::error::DateDurationParseError#Enum icu::calendar::error::EcmaReferenceYearError#Enum icu::calendar::options::DateAddOptions#Struct From 51f7590c0f1b64e80f0d98e60215e927318a097f Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 21:20:44 -0800 Subject: [PATCH 05/18] use year_info_from_extended_checked in arithmetic code --- .../calendar/src/calendar_arithmetic.rs | 66 ++++++++++++++----- components/calendar/src/error.rs | 5 ++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 5aef72015e9..d063d89030d 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -525,7 +525,16 @@ impl ArithmeticDate { /// /// This takes a year, month, and day, where the month and day might be out of range, then /// balances excess months into the year field and excess days into the month field. - pub(crate) fn new_balanced(year: C::YearInfo, ordinal_month: i64, day: i64, cal: &C) -> Self { + /// + /// In addition to specced behavior, this guarantees that it will produce an in-generous-year-range date. + /// + /// This does *not* necessarily produce something in RD range + pub(crate) fn new_balanced( + year: C::YearInfo, + ordinal_month: i64, + day: i64, + cal: &C, + ) -> Result { // 1. Let _resolvedYear_ be _arithmeticYear_. // 1. Let _resolvedMonth_ be _ordinalMonth_. let mut resolved_year = year; @@ -537,7 +546,8 @@ impl ArithmeticDate { // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). // 1. Set _resolvedMonth_ to _resolvedMonth_ + _monthsInYear_. while resolved_month <= 0 { - resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() - 1); + resolved_year = + cal.year_info_from_extended_checked(resolved_year.to_extended_year() - 1)?; months_in_year = C::months_in_provided_year(resolved_year); resolved_month += i64::from(months_in_year); } @@ -547,7 +557,8 @@ impl ArithmeticDate { // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). while resolved_month > i64::from(months_in_year) { resolved_month -= i64::from(months_in_year); - resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() + 1); + resolved_year = + cal.year_info_from_extended_checked(resolved_year.to_extended_year() + 1)?; months_in_year = C::months_in_provided_year(resolved_year); } debug_assert!(u8::try_from(resolved_month).is_ok()); @@ -565,7 +576,8 @@ impl ArithmeticDate { // 1. Set _resolvedYear_ to _resolvedYear_ - 1. // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). // 1. Set _resolvedMonth_ to _monthsInYear_. - resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() - 1); + resolved_year = + cal.year_info_from_extended_checked(resolved_year.to_extended_year() - 1)?; months_in_year = C::months_in_provided_year(resolved_year); resolved_month = months_in_year; } @@ -585,7 +597,8 @@ impl ArithmeticDate { // 1. Set _resolvedYear_ to _resolvedYear_ + 1. // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). // 1. Set _resolvedMonth_ to 1. - resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() + 1); + resolved_year = + cal.year_info_from_extended_checked(resolved_year.to_extended_year() + 1)?; months_in_year = C::months_in_provided_year(resolved_year); resolved_month = 1; } @@ -595,8 +608,11 @@ impl ArithmeticDate { debug_assert!(u8::try_from(resolved_day).is_ok()); let resolved_day = resolved_day as u8; // 1. Return the Record { [[Year]]: _resolvedYear_, [[Month]]: _resolvedMonth_, [[Day]]: _resolvedDay_ }. - // TODO: does not obey precondition - Self::new_unchecked(resolved_year, resolved_month, resolved_day) + Ok(Self::new_unchecked( + resolved_year, + resolved_month, + resolved_day, + )) } /// Implements the Temporal abstract operation `CompareSurpasses` based on month code @@ -724,7 +740,11 @@ impl ArithmeticDate { } }; // 1. Let _monthsAdded_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _months_, 1). - let months_added = Self::new_balanced(y0, duration.add_months_to(m0), 1, cal); + let Ok(months_added) = Self::new_balanced(y0, duration.add_months_to(m0), 1, cal) else { + // Any operation that brings us out of range will have surpassed any valid date input + // we might have received + return true; + }; // 1. If CompareSurpasses(_sign_, _monthsAdded_.[[Year]], _monthsAdded_.[[Month]], _parts_.[[Day]], _calDate2_) is *true*, return *true*. if Self::compare_surpasses_ordinal( sign, @@ -740,12 +760,14 @@ impl ArithmeticDate { return false; } // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _monthsAdded_.[[Year]], _monthsAdded_.[[Month]] + 1, 0). - let end_of_month = Self::new_balanced( + let Ok(end_of_month) = Self::new_balanced( months_added.year(), i64::from(months_added.month()) + 1, 0, cal, - ); + ) else { + return true; + }; // 1. Let _baseDay_ be _parts_.[[Day]]. let base_day = parts.day(); // 1. If _baseDay_ ≤ _endOfMonth_.[[Day]], then @@ -760,12 +782,15 @@ impl ArithmeticDate { // 1. Let _daysInWeek_ be 7 (the number of days in a week for all supported calendars). // 1. Let _balancedDate_ be BalanceNonISODate(_calendar_, _endOfMonth_.[[Year]], _endOfMonth_.[[Month]], _regulatedDay_ + _daysInWeek_ * _weeks_ + _days_). // 1. Return CompareSurpasses(_sign_, _balancedDate_.[[Year]], _balancedDate_.[[Month]], _balancedDate_.[[Day]], _calDate2_). - let balanced_date = Self::new_balanced( + let Ok(balanced_date) = Self::new_balanced( end_of_month.year(), i64::from(end_of_month.month()), duration.add_weeks_and_days_to(regulated_day), cal, - ); + ) else { + return true; + }; + Self::compare_surpasses_ordinal( sign, balanced_date.year(), @@ -787,7 +812,9 @@ impl ArithmeticDate { ) -> Result { // 1. Let _parts_ be CalendarISOToDate(_calendar_, _isoDate_). // 1. Let _y0_ be _parts_.[[Year]] + _duration_.[[Years]]. - let y0 = cal.year_info_from_extended(duration.add_years_to(self.year().to_extended_year())); + let y0 = cal.year_info_from_extended_checked( + duration.add_years_to(self.year().to_extended_year()), + )?; // 1. Let _m0_ be MonthCodeToOrdinal(_calendar_, _y0_, ! ConstrainMonthCode(_calendar_, _y0_, _parts_.[[MonthCode]], _overflow_)). let base_month = cal.month_from_ordinal(self.year(), self.month()); let m0 = cal.ordinal_from_month( @@ -796,7 +823,7 @@ impl ArithmeticDate { DateFromFieldsOptions::from_add_options(options), )?; // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _duration_.[[Months]] + 1, 0). - let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal); + let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal)?; // 1. Let _baseDay_ be _parts_.[[Day]]. let base_day = self.day(); // 1. If _baseDay_ ≤ _endOfMonth_.[[Day]], then @@ -814,15 +841,22 @@ impl ArithmeticDate { } end_of_month.day() }; + // 1. Let _balancedDate_ be BalanceNonISODate(_calendar_, _endOfMonth_.[[Year]], _endOfMonth_.[[Month]], _regulatedDay_ + 7 * _duration_.[[Weeks]] + _duration_.[[Days]]). // 1. Let _result_ be ? CalendarIntegersToISO(_calendar_, _balancedDate_.[[Year]], _balancedDate_.[[Month]], _balancedDate_.[[Day]]). // 1. Return _result_. - Ok(Self::new_balanced( + let balanced = Self::new_balanced( end_of_month.year(), i64::from(end_of_month.month()), duration.add_weeks_and_days_to(regulated_day), cal, - )) + )?; + // We early checked for a generous range of years, now we must check + // to ensure we are actually in range for our core invariant. + if !VALID_RD_RANGE.contains(&balanced.to_rata_die()) { + return Err(DateAddError::Overflow); + } + Ok(balanced) } /// Implements the Temporal abstract operation `NonISODateUntil`. diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index ce3266b47b7..1d9c474d3df 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -524,6 +524,11 @@ impl From for DateFromFieldsError { } } +impl From for DateAddError { + fn from(_other: YearOverflowError) -> Self { + DateAddError::Overflow + } +} /// Error for [`Month`](crate::types::Month) parsing #[derive(Debug)] #[non_exhaustive] From ad51e40796a72208d1f55441b3f1d3fce9a9d9a3 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 21:43:42 -0800 Subject: [PATCH 06/18] Add preemptive check for date addition --- .../calendar/src/calendar_arithmetic.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index d063d89030d..3cf11c9f2d9 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -810,6 +810,27 @@ impl ArithmeticDate { cal: &C, options: DateAddOptions, ) -> Result { + // We preemptively protect ourselves from overly-large values + // + // This is mostly needed for avoiding overflows on Duration arithmetic, + // this could also be handled by updating DateDuration::add_foo_to() + // to return an error. However, that would lead to more error handling cluttering + // surpasses(). + const GENEROUS_MAX_YEARS: u32 = + (*GENEROUS_YEAR_RANGE.end() - *GENEROUS_YEAR_RANGE.start()) as u32; + const GENEROUS_MAX_MONTHS: u32 = GENEROUS_MAX_YEARS * 13; + const GENEROUS_MAX_DAYS: u64 = GENEROUS_MAX_MONTHS as u64 * 31; + + if duration.years > GENEROUS_MAX_YEARS + || duration.months > GENEROUS_MAX_MONTHS + || (duration.weeks as u64) + .saturating_mul(7) + .saturating_add(duration.days) + > GENEROUS_MAX_DAYS + { + return Err(DateAddError::Overflow); + } + // 1. Let _parts_ be CalendarISOToDate(_calendar_, _isoDate_). // 1. Let _y0_ be _parts_.[[Year]] + _duration_.[[Years]]. let y0 = cal.year_info_from_extended_checked( From 19f2e02478d9b308c862368d4752b39415126fdf Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 21:30:40 -0800 Subject: [PATCH 07/18] Update fuzzer --- components/calendar/fuzz/fuzz_targets/add.rs | 48 +------------------ .../calendar/fuzz/fuzz_targets/common.rs | 6 +-- 2 files changed, 3 insertions(+), 51 deletions(-) diff --git a/components/calendar/fuzz/fuzz_targets/add.rs b/components/calendar/fuzz/fuzz_targets/add.rs index 07842261a48..7693224b664 100644 --- a/components/calendar/fuzz/fuzz_targets/add.rs +++ b/components/calendar/fuzz/fuzz_targets/add.rs @@ -41,50 +41,6 @@ impl From for icu_calendar::types::DateDuration { } } -impl DateDuration { - /// Temporal doesn't care about dates outside of -271821-04-20 to +275760-09-13 - /// and will not let you attempt to add up to them even with overflow: constrain. - /// - /// We should eventually be applying some limits to this code in ICU4X. - /// We currently do not and `Date::try_added()` will panic for large `days` values. - /// - /// - /// - /// For now, fuzz what we can for Temporal's needs. - /// - /// This code is copied from - /// so that we are testing temporal_rs behavior. - fn is_valid_for_temporal(&self) -> bool { - // Temporal range is -271821-04-20 to +275760-09-13 - // This is (roughly) the maximum year duration that can exist for ISO - const TEMPORAL_MAX_ISO_YEAR_DURATION: u32 = 275760 + 271821; - // Double it. No calendar has years that are half the size of ISO years. - const YEAR_DURATION: u32 = 2 * TEMPORAL_MAX_ISO_YEAR_DURATION; - // Assume every year is a leap year, calculate a month range - const MONTH_DURATION: u32 = YEAR_DURATION * 13; - // Our longest year is 390 days - const DAY_DURATION: u32 = YEAR_DURATION * 390; - const WEEK_DURATION: u32 = DAY_DURATION / 7; - - - if self.years > YEAR_DURATION { - return false; - } - if self.months > MONTH_DURATION { - return false; - } - if self.weeks > WEEK_DURATION { - return false; - } - if self.days > DAY_DURATION.into() { - return false; - } - - true - - } -} - fuzz_target!(|data: FuzzInput| { let Some(date) = data.ymd.to_date(data.cal, true) else { return }; @@ -94,8 +50,6 @@ fuzz_target!(|data: FuzzInput| { } else { Some(Overflow::Reject) }; - if !data.duration.is_valid_for_temporal() { - return; - } + let _ = date.try_added_with_options(data.duration.into(), options); }); diff --git a/components/calendar/fuzz/fuzz_targets/common.rs b/components/calendar/fuzz/fuzz_targets/common.rs index 6b00141ea13..9d557758483 100644 --- a/components/calendar/fuzz/fuzz_targets/common.rs +++ b/components/calendar/fuzz/fuzz_targets/common.rs @@ -4,7 +4,7 @@ use arbitrary::Arbitrary; use icu_calendar::{Date, AnyCalendar}; -use icu_calendar::types::{DateFields, MonthCode}; +use icu_calendar::types::{DateFields, Month}; use icu_calendar::options::*; @@ -28,8 +28,6 @@ impl Ymd { Some(Overflow::Reject) }; - let code: MonthCode; - let mut fields = DateFields::default(); fields.extended_year = Some(self.year); fields.day = Some(self.day); @@ -93,7 +91,7 @@ impl From for icu_calendar::AnyCalendarKind { AnyCalendarKind::HijriTabularTypeIIThursday => Self::HijriTabularTypeIIThursday, AnyCalendarKind::HijriUmmAlQura => Self::HijriUmmAlQura, AnyCalendarKind::Iso => Self::Iso, - AnyCalendarKind::Japanese | AnyCalendarKind::JapaneseExtended => Self::Japanese, + AnyCalendarKind::Japanese => Self::Japanese, AnyCalendarKind::Persian => Self::Persian, AnyCalendarKind::Roc => Self::Roc, } From 2390e3f51fa63d673d11d8425879cffc5ce3cc55 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Thu, 19 Feb 2026 22:25:07 -0800 Subject: [PATCH 08/18] Add test for arithmetic --- components/calendar/src/tests/extrema.rs | 119 ++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/components/calendar/src/tests/extrema.rs b/components/calendar/src/tests/extrema.rs index 2edce53a65e..03de086368d 100644 --- a/components/calendar/src/tests/extrema.rs +++ b/components/calendar/src/tests/extrema.rs @@ -3,9 +3,11 @@ // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). use crate::calendar_arithmetic::{GENEROUS_YEAR_RANGE, VALID_RD_RANGE, VALID_YEAR_RANGE}; +use crate::duration::DateDuration; +use crate::error::DateAddError; use crate::error::DateFromFieldsError; -use crate::options::{DateFromFieldsOptions, Overflow}; -use crate::types::{DateFields, Month}; +use crate::options::{DateAddOptions, DateDifferenceOptions, DateFromFieldsOptions, Overflow}; +use crate::types::{DateDurationUnit, DateFields, Month}; use crate::Date; use calendrical_calculations::gregorian::fixed_from_gregorian; use calendrical_calculations::rata_die::RataDie; @@ -53,6 +55,119 @@ super::test_all_cals!( } ); +// Add one `unit` to a signed duration (in the direction of its sign) +fn nudge_duration_by_unit( + mut duration: DateDuration, + unit: DateDurationUnit, + value: i32, +) -> DateDuration { + match unit { + DateDurationUnit::Years => duration.years = duration.years.saturating_add_signed(value), + DateDurationUnit::Months => duration.months = duration.months.saturating_add_signed(value), + DateDurationUnit::Weeks => duration.weeks = duration.weeks.saturating_add_signed(value), + DateDurationUnit::Days => { + duration.days = duration.days.saturating_add_signed(i64::from(value)) + } + } + duration +} + +super::test_all_cals!( + #[ignore] // slow + fn check_added_extrema(cal: C) { + let min_date = Date::from_rata_die(*VALID_RD_RANGE.start(), cal); + let max_date = Date::from_rata_die(*VALID_RD_RANGE.end(), cal); + const RDS_TO_TEST: &[RataDie] = &[ + *VALID_RD_RANGE.start(), + VALID_RD_RANGE.start().add(1), + VALID_RD_RANGE.start().add(5), + VALID_RD_RANGE.start().add(100), + VALID_RD_RANGE.start().add(10000), + RataDie::new(-1000), + RataDie::new(0), + RataDie::new(1000), + VALID_RD_RANGE.end().add(10000), + VALID_RD_RANGE.end().add(100), + VALID_RD_RANGE.end().add(5), + VALID_RD_RANGE.end().add(1), + *VALID_RD_RANGE.end(), + ]; + + let constrain = DateAddOptions { + overflow: Some(Overflow::Constrain), + ..Default::default() + }; + + let reject = DateAddOptions { + overflow: Some(Overflow::Reject), + ..Default::default() + }; + + for start_date in RDS_TO_TEST { + let start_date = Date::from_rata_die(*start_date, cal); + for unit in [ + DateDurationUnit::Years, + // This is very very slow right now + // https://github.com/unicode-org/icu4x/issues/7077 + // DateDurationUnit::Months, + DateDurationUnit::Weeks, + DateDurationUnit::Days, + ] { + let options = DateDifferenceOptions { + largest_unit: Some(unit), + ..Default::default() + }; + let min_duration = start_date + .try_until_with_options(&min_date, options) + .unwrap(); + let max_duration = start_date + .try_until_with_options(&max_date, options) + .unwrap(); + + // the nudge routine assumes a sign, but zero durations always have positive sign + // Explicitly set it before nudging + let mut min_duration_for_nudging = min_duration; + min_duration_for_nudging.is_negative = true; + let min_duration_plus_one = + nudge_duration_by_unit(min_duration_for_nudging, unit, 1); + let max_duration_plus_one = nudge_duration_by_unit(max_duration, unit, 1); + + for (date_bound, duration_bound, plus_one, bound) in [ + (min_date, min_duration, min_duration_plus_one, "min"), + (max_date, max_duration, max_duration_plus_one, "max"), + ] { + for overflow in [constrain, reject] { + let added = start_date.try_added_with_options(duration_bound, overflow); + if added.is_err() && overflow == reject { + assert_ne!(added, Err(DateAddError::Overflow), "{start_date:?} + {duration_bound:?} should not produce overflow error in Reject mode"); + } else { + assert_eq!( + added, + Ok(date_bound), + "{start_date:?} + {duration_bound:?} should produce {bound} date" + ); + } + + let added_plus_one = start_date.try_added_with_options(plus_one, overflow); + if overflow == constrain { + assert_eq!( + added_plus_one, + Err(DateAddError::Overflow), + "{start_date:?} + {plus_one:?} should be out of range" + ); + } else { + assert!( + added_plus_one.is_err(), + "{start_date:?} + {plus_one:?} should be out of range" + ); + } + } + } + } + } + } +); + super::test_all_cals!( fn check_from_fields_extrema(cal: C) { let min_date = Date::from_rata_die(*VALID_RD_RANGE.start(), cal); From 27cd5b60da1a89fb43a02b1a1284a408823c377d Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 09:30:49 -0800 Subject: [PATCH 09/18] Review comments --- .../calendar/src/calendar_arithmetic.rs | 68 +++++++++++-------- components/calendar/src/error.rs | 17 ++--- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 3cf11c9f2d9..590146b1a9b 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -31,6 +31,8 @@ pub const VALID_RD_RANGE: RangeInclusive = calendrical_calculations::gregorian::fixed_from_gregorian(-999999, 1, 1) ..=calendrical_calculations::gregorian::fixed_from_gregorian(999999, 12, 31); +/// This is a year range that exceeds the RD range for all calendars. +/// /// We *must* ensure dates are within `VALID_RD_RANGE` some point before constructing them. /// /// However, we may need to perform a fair amount of calendar arithmetic before @@ -38,14 +40,15 @@ pub const VALID_RD_RANGE: RangeInclusive = /// is fragile (chance of math issues, slowness, debug assertions) at high ranges. /// /// So we try to early-check year values where possible. We use a "generous" year range -/// which is known to be wider than the valid year range for any era in any currently +/// which is known to be wider than the valid RD range for any era in any currently /// supported calendar. /// /// `VALID_RD_RANGE` maps to 1031332 BH..=1030050 AH in the Islamic calendars, which have -/// the shortest years. We pick a slightly wider +/// the shortest years. We pick a slightly wider range so that temporary intermediate +/// values are not affected by this. /// -/// The tests in `extrema.rs` ensure that all in-range dates can be produced here, -/// and that these year numbers map to out-of-range values for every era. +/// The tests in `extrema.rs` ensure that all in-range dates can be produced by the APIs that, +/// use this check, and that these year numbers map to out-of-range values for every era. pub const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_000; // Invariant: VALID_RD_RANGE contains the date @@ -156,6 +159,9 @@ pub(crate) trait DateFieldsResolver: Calendar { /// Converts the era and era year to a [`Self::YearInfo`]. If the calendar does not have eras, /// this should always return an Err result. + /// + /// This function does not guard against overflow, please range-check input years + /// against [`VALID_YEAR_RANGE`] or [`GENEROUS_YEAR_RANGE`] before passing to it. fn extended_year_from_era_year( &self, era: &[u8], @@ -251,7 +257,10 @@ impl ArithmeticDate { C::YearInfo::unpack_day(self.0) } - // Precondition: the date is in the VALID_RD_RANGE + /// Precondition: the date is in `GENEROUS_YEAR_RANGE`. + /// + /// If giving this date to *users*, it must also be in `VALID_RD_RANGE`. `GENEROUS_YEAR_RANGE` + /// is fine to use for intermediate dates. #[inline] pub(crate) fn new_unchecked(year: C::YearInfo, month: u8, day: u8) -> Self { ArithmeticDate(C::YearInfo::pack(year, month, day)) @@ -270,15 +279,14 @@ impl ArithmeticDate { calendar: &C, ) -> Result { let extended_year = if let Some(era) = era { - let year = calendar.extended_year_from_era_year( + calendar.extended_year_from_era_year( era.as_bytes(), - range_check(year, "year", VALID_YEAR_RANGE)?, - )?; - range_check(year, "era_year", VALID_YEAR_RANGE)?; - year + range_check(year, "era_year", VALID_YEAR_RANGE)?, + )? } else { - range_check(year, "extended_year", VALID_YEAR_RANGE)? + year }; + let extended_year = range_check(extended_year, "extended_year", VALID_YEAR_RANGE)?; let year = calendar.year_info_from_extended(extended_year); let validated = Month::try_from_utf8(month_code.0.as_bytes()).map_err(|e| match e { MonthCodeParseError::InvalidSyntax => DateError::UnknownMonthCode(month_code), @@ -361,10 +369,6 @@ impl ArithmeticDate { let year = match (fields.era, fields.era_year) { (None, None) => match fields.extended_year { Some(extended_year) => { - if !GENEROUS_YEAR_RANGE.contains(&extended_year) { - return Err(DateFromFieldsError::Overflow); - } - calendar.year_info_from_extended_checked(extended_year)? } None => match missing_fields_strategy { @@ -399,9 +403,6 @@ impl ArithmeticDate { return Err(DateFromFieldsError::Overflow); } let extended_year = calendar.extended_year_from_era_year(era, era_year)?; - if !GENEROUS_YEAR_RANGE.contains(&extended_year) { - return Err(DateFromFieldsError::Overflow); - } let year = calendar.year_info_from_extended_checked(extended_year)?; if let Some(extended_year) = fields.extended_year { if year.to_extended_year() != extended_year { @@ -508,12 +509,7 @@ impl ArithmeticDate { range_check(year, "era_year", VALID_YEAR_RANGE)?, )?; // check the extended year in terms of the year - let offset = year - extended_year; - range_check( - year, // == year_info.to_extended_year() + offset - "extended_year", - (VALID_YEAR_RANGE.start() + offset)..=(VALID_YEAR_RANGE.end() + offset), - )?; + range_check(extended_year, "extended_year", VALID_YEAR_RANGE)?; let year_info = cal.year_info_from_extended(extended_year); range_check(month, "month", 1..=C::months_in_provided_year(year_info))?; range_check(day, "day", 1..=C::days_in_provided_month(year_info, month))?; @@ -528,7 +524,8 @@ impl ArithmeticDate { /// /// In addition to specced behavior, this guarantees that it will produce an in-generous-year-range date. /// - /// This does *not* necessarily produce something in RD range + /// This does *not* necessarily produce something within [`VALID_RD_RANGE`], but it will ensure things + /// are within [`GENEROUS_YEAR_RANGE`]. pub(crate) fn new_balanced( year: C::YearInfo, ordinal_month: i64, @@ -609,6 +606,8 @@ impl ArithmeticDate { let resolved_day = resolved_day as u8; // 1. Return the Record { [[Year]]: _resolvedYear_, [[Month]]: _resolvedMonth_, [[Day]]: _resolvedDay_ }. Ok(Self::new_unchecked( + // We can be sure this is within `GENEROUS_YEAR_RANGE` since + // we have consistently used year_info_from_extended_checked. resolved_year, resolved_month, resolved_day, @@ -838,11 +837,19 @@ impl ArithmeticDate { )?; // 1. Let _m0_ be MonthCodeToOrdinal(_calendar_, _y0_, ! ConstrainMonthCode(_calendar_, _y0_, _parts_.[[MonthCode]], _overflow_)). let base_month = cal.month_from_ordinal(self.year(), self.month()); - let m0 = cal.ordinal_from_month( + let m0_result = cal.ordinal_from_month( y0, base_month, DateFromFieldsOptions::from_add_options(options), - )?; + ); + let m0 = match m0_result { + Ok(m0) => m0, + Err(MonthError::NotInCalendar) => { + debug_assert!(false, "Should never get NotInCalendar when performing arithmetic"); + return Err(DateAddError::MonthNotInYear) + } + Err(MonthError::NotInYear) => return Err(DateAddError::MonthNotInYear) + }; // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _duration_.[[Months]] + 1, 0). let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal)?; // 1. Let _baseDay_ be _parts_.[[Day]]. @@ -872,8 +879,11 @@ impl ArithmeticDate { duration.add_weeks_and_days_to(regulated_day), cal, )?; - // We early checked for a generous range of years, now we must check - // to ensure we are actually in range for our core invariant. + + // We early checked for a generous range of years, and `new_balanced` + // ensures the date stays within the generous range, now we must check + // to ensure we are actually in range for our core invariant before + // returning a date that is not internal to this API. if !VALID_RD_RANGE.contains(&balanced.to_rata_die()) { return Err(DateAddError::Overflow); } diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index 1d9c474d3df..83ae04c4689 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -481,12 +481,13 @@ mod unstable { #[displaydoc("Invalid day for month, max is {max}")] InvalidDay { /// The maximum allowed value (the minimum is 1). + /// + /// This is only possible with [`Overflow::Reject`](crate::options::Overflow::Reject). max: u8, }, - /// The specified month does not exist in this calendar. - #[displaydoc("The specified month does not exist in this calendar")] - MonthNotInCalendar, /// The specified month exists in this calendar, but not in the specified year. + /// + /// This is only possible with [`Overflow::Reject`](crate::options::Overflow::Reject). #[displaydoc("The specified month exists in this calendar, but not for this year")] MonthNotInYear, /// The date is out of range. @@ -563,16 +564,6 @@ impl From for DateFromFieldsError { } } -impl From for DateAddError { - #[inline] - fn from(value: MonthError) -> Self { - match value { - MonthError::NotInCalendar => DateAddError::MonthNotInCalendar, - MonthError::NotInYear => DateAddError::MonthNotInYear, - } - } -} - impl From for LunisolarDateError { #[inline] fn from(value: MonthError) -> Self { From 843625adcb59bdbd69967f6b4a7c2cd3debff39a Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 10:29:51 -0800 Subject: [PATCH 10/18] VALID_YEAR_RANGE to CONSTRUCTOR_YEAR_RANGE --- components/calendar/src/cal/iso.rs | 10 +- .../calendar/src/calendar_arithmetic.rs | 50 ++++--- components/calendar/src/tests/exhaustive.rs | 4 +- components/calendar/src/tests/extrema.rs | 135 ++++++++++-------- 4 files changed, 114 insertions(+), 85 deletions(-) diff --git a/components/calendar/src/cal/iso.rs b/components/calendar/src/cal/iso.rs index 0f8a36b5cb1..e0bdc55dd10 100644 --- a/components/calendar/src/cal/iso.rs +++ b/components/calendar/src/cal/iso.rs @@ -91,7 +91,7 @@ impl Iso { mod test { use super::*; use crate::{ - calendar_arithmetic::{VALID_RD_RANGE, VALID_YEAR_RANGE}, + calendar_arithmetic::{CONSTRUCTOR_YEAR_RANGE, VALID_RD_RANGE}, types::{DateDuration, RataDie, Weekday}, }; @@ -127,7 +127,7 @@ mod test { }, // Lowest allowed YMD TestCase { - year: *VALID_YEAR_RANGE.start(), + year: *CONSTRUCTOR_YEAR_RANGE.start(), month: 1, day: 1, rd: RataDie::new(-3652424), @@ -136,7 +136,7 @@ mod test { }, // Highest allowed YMD TestCase { - year: *VALID_YEAR_RANGE.end(), + year: *CONSTRUCTOR_YEAR_RANGE.end(), month: 12, day: 31, rd: RataDie::new(3652059), @@ -181,8 +181,8 @@ mod test { Err(RangeError { field: "year", value: case.year, - min: *VALID_YEAR_RANGE.start(), - max: *VALID_YEAR_RANGE.end() + min: *CONSTRUCTOR_YEAR_RANGE.start(), + max: *CONSTRUCTOR_YEAR_RANGE.end() }), "{case:?}" ) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 590146b1a9b..feee66ba015 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -19,10 +19,10 @@ use core::hash::{Hash, Hasher}; use core::ops::RangeInclusive; /// This is checked by constructors. Internally we don't care about this invariant. -pub const VALID_YEAR_RANGE: RangeInclusive = -9999..=9999; +pub const CONSTRUCTOR_YEAR_RANGE: RangeInclusive = -9999..=9999; /// This is a fundamental invariant of `ArithmeticDate` and by extension all our -/// date types. Because this range exceeds the [`VALID_YEAR_RANGE`], only +/// date types. Because this range exceeds the [`CONSTRUCTOR_YEAR_RANGE`], only /// the valid year range is checked in most constructors. /// /// This is the range used by `Date::from_rata_die`, `Date::try_from_fields`, @@ -161,7 +161,7 @@ pub(crate) trait DateFieldsResolver: Calendar { /// this should always return an Err result. /// /// This function does not guard against overflow, please range-check input years - /// against [`VALID_YEAR_RANGE`] or [`GENEROUS_YEAR_RANGE`] before passing to it. + /// against [`CONSTRUCTOR_YEAR_RANGE`] or [`GENEROUS_YEAR_RANGE`] before passing to it. fn extended_year_from_era_year( &self, era: &[u8], @@ -270,7 +270,7 @@ impl ArithmeticDate { ArithmeticDate::new_unchecked(self.year(), self.month(), self.day()) } - // Used by `from_codes`, checks `VALID_YEAR_RANGE` + // Used by `from_codes`, checks `CONSTRUCTOR_YEAR_RANGE` pub(crate) fn from_era_year_month_code_day( era: Option<&str>, year: i32, @@ -281,12 +281,12 @@ impl ArithmeticDate { let extended_year = if let Some(era) = era { calendar.extended_year_from_era_year( era.as_bytes(), - range_check(year, "era_year", VALID_YEAR_RANGE)?, + range_check(year, "era_year", CONSTRUCTOR_YEAR_RANGE)?, )? } else { year }; - let extended_year = range_check(extended_year, "extended_year", VALID_YEAR_RANGE)?; + let extended_year = range_check(extended_year, "extended_year", CONSTRUCTOR_YEAR_RANGE)?; let year = calendar.year_info_from_extended(extended_year); let validated = Month::try_from_utf8(month_code.0.as_bytes()).map_err(|e| match e { MonthCodeParseError::InvalidSyntax => DateError::UnknownMonthCode(month_code), @@ -305,14 +305,14 @@ impl ArithmeticDate { Ok(ArithmeticDate::new_unchecked(year, month, day)) } - // Used by calendar-specific constructors (lunisolar), checks `VALID_YEAR_RANGE` + // Used by calendar-specific constructors (lunisolar), checks `CONSTRUCTOR_YEAR_RANGE` pub(crate) fn try_from_ymd_lunisolar( year: i32, month: Month, day: u8, calendar: &C, ) -> Result { - if !VALID_YEAR_RANGE.contains(&year) { + if !CONSTRUCTOR_YEAR_RANGE.contains(&year) { return Err(LunisolarDateError::InvalidYear); } let year = calendar.year_info_from_extended(year); @@ -368,9 +368,7 @@ impl ArithmeticDate { // so we make an attempt. let year = match (fields.era, fields.era_year) { (None, None) => match fields.extended_year { - Some(extended_year) => { - calendar.year_info_from_extended_checked(extended_year)? - } + Some(extended_year) => calendar.year_info_from_extended_checked(extended_year)?, None => match missing_fields_strategy { MissingFieldsStrategy::Reject => { return Err(DateFromFieldsError::NotEnoughFields) @@ -475,20 +473,21 @@ impl ArithmeticDate { Ok(Self::new_unchecked(year, month, day)) } - // Used by calendar-specific constructors (non-lunisolar), checks `VALID_YEAR_RANGE` + // Used by calendar-specific constructors (non-lunisolar), checks `CONSTRUCTOR_YEAR_RANGE` pub(crate) fn from_year_month_day( year: i32, month: u8, day: u8, cal: &C, ) -> Result { - let year_info = cal.year_info_from_extended(range_check(year, "year", VALID_YEAR_RANGE)?); + let year_info = + cal.year_info_from_extended(range_check(year, "year", CONSTRUCTOR_YEAR_RANGE)?); // check the extended year in terms of the year let offset = year - year_info.to_extended_year(); range_check( year, // == year_info.to_extended_year() + offset "year", - (VALID_YEAR_RANGE.start() + offset)..=(VALID_YEAR_RANGE.end() + offset), + (CONSTRUCTOR_YEAR_RANGE.start() + offset)..=(CONSTRUCTOR_YEAR_RANGE.end() + offset), )?; range_check(month, "month", 1..=C::months_in_provided_year(year_info))?; range_check(day, "day", 1..=C::days_in_provided_month(year_info, month))?; @@ -496,7 +495,7 @@ impl ArithmeticDate { Ok(ArithmeticDate::new_unchecked(year_info, month, day)) } - // Used by calendar-specific constructors (Japanese), checks `VALID_YEAR_RANGE` + // Used by calendar-specific constructors (Japanese), checks `CONSTRUCTOR_YEAR_RANGE` pub(crate) fn from_era_year_month_day( era: &str, year: i32, @@ -506,10 +505,10 @@ impl ArithmeticDate { ) -> Result { let extended_year = cal.extended_year_from_era_year( era.as_bytes(), - range_check(year, "era_year", VALID_YEAR_RANGE)?, + range_check(year, "era_year", CONSTRUCTOR_YEAR_RANGE)?, )?; // check the extended year in terms of the year - range_check(extended_year, "extended_year", VALID_YEAR_RANGE)?; + range_check(extended_year, "extended_year", CONSTRUCTOR_YEAR_RANGE)?; let year_info = cal.year_info_from_extended(extended_year); range_check(month, "month", 1..=C::months_in_provided_year(year_info))?; range_check(day, "day", 1..=C::days_in_provided_month(year_info, month))?; @@ -845,10 +844,13 @@ impl ArithmeticDate { let m0 = match m0_result { Ok(m0) => m0, Err(MonthError::NotInCalendar) => { - debug_assert!(false, "Should never get NotInCalendar when performing arithmetic"); - return Err(DateAddError::MonthNotInYear) + debug_assert!( + false, + "Should never get NotInCalendar when performing arithmetic" + ); + return Err(DateAddError::MonthNotInYear); } - Err(MonthError::NotInYear) => return Err(DateAddError::MonthNotInYear) + Err(MonthError::NotInYear) => return Err(DateAddError::MonthNotInYear), }; // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _duration_.[[Months]] + 1, 0). let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal)?; @@ -1091,8 +1093,12 @@ mod tests { ]; // Valid RDs can represent all valid years - assert!(lowest_years.iter().all(|y| y <= VALID_YEAR_RANGE.start())); - assert!(highest_years.iter().all(|y| y >= VALID_YEAR_RANGE.end())); + assert!(lowest_years + .iter() + .all(|y| y <= CONSTRUCTOR_YEAR_RANGE.start())); + assert!(highest_years + .iter() + .all(|y| y >= CONSTRUCTOR_YEAR_RANGE.end())); // All years are 21-bits assert!(-lowest_years.iter().copied().min().unwrap() < 1 << 20); diff --git a/components/calendar/src/tests/exhaustive.rs b/components/calendar/src/tests/exhaustive.rs index bd2d81b3a48..39f14d1f6e3 100644 --- a/components/calendar/src/tests/exhaustive.rs +++ b/components/calendar/src/tests/exhaustive.rs @@ -2,7 +2,7 @@ // called LICENSE at the top level of the ICU4X source tree // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). -use crate::calendar_arithmetic::{VALID_RD_RANGE, VALID_YEAR_RANGE}; +use crate::calendar_arithmetic::{CONSTRUCTOR_YEAR_RANGE, VALID_RD_RANGE}; use crate::*; // Check rd -> date -> iso -> date -> rd for whole range @@ -36,7 +36,7 @@ super::test_all_cals!( let months = (1..19) .flat_map(|i| [types::Month::new(i), types::Month::leap(i)].into_iter()) .collect::>(); - for year in VALID_YEAR_RANGE { + for year in CONSTRUCTOR_YEAR_RANGE { if year % 50000 == 0 { println!("{} {year:?}", cal.as_calendar().debug_name()); } diff --git a/components/calendar/src/tests/extrema.rs b/components/calendar/src/tests/extrema.rs index 03de086368d..903b3c3947c 100644 --- a/components/calendar/src/tests/extrema.rs +++ b/components/calendar/src/tests/extrema.rs @@ -2,7 +2,7 @@ // called LICENSE at the top level of the ICU4X source tree // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). -use crate::calendar_arithmetic::{GENEROUS_YEAR_RANGE, VALID_RD_RANGE, VALID_YEAR_RANGE}; +use crate::calendar_arithmetic::{CONSTRUCTOR_YEAR_RANGE, GENEROUS_YEAR_RANGE, VALID_RD_RANGE}; use crate::duration::DateDuration; use crate::error::DateAddError; use crate::error::DateFromFieldsError; @@ -326,19 +326,25 @@ super::test_all_cals!( // Success Date::try_new_from_codes( None, - *VALID_YEAR_RANGE.start(), + *CONSTRUCTOR_YEAR_RANGE.start(), + Month::new(1).code(), + 1, + cal, + ) + .unwrap(); + Date::try_new_from_codes( + None, + *CONSTRUCTOR_YEAR_RANGE.end(), Month::new(1).code(), 1, cal, ) .unwrap(); - Date::try_new_from_codes(None, *VALID_YEAR_RANGE.end(), Month::new(1).code(), 1, cal) - .unwrap(); // Error Date::try_new_from_codes( None, - *VALID_YEAR_RANGE.start() - 1, + *CONSTRUCTOR_YEAR_RANGE.start() - 1, Month::new(1).code(), 1, cal, @@ -346,7 +352,7 @@ super::test_all_cals!( .unwrap_err(); Date::try_new_from_codes( None, - *VALID_YEAR_RANGE.end() + 1, + *CONSTRUCTOR_YEAR_RANGE.end() + 1, Month::new(1).code(), 1, cal, @@ -355,7 +361,7 @@ super::test_all_cals!( if let crate::types::YearInfo::Era(y) = Date::try_new_from_codes( None, - *VALID_YEAR_RANGE.start(), + *CONSTRUCTOR_YEAR_RANGE.start(), Month::new(1).code(), 1, cal, @@ -365,7 +371,7 @@ super::test_all_cals!( { Date::try_new_from_codes( Some(&y.era), - *VALID_YEAR_RANGE.start() - 1, + *CONSTRUCTOR_YEAR_RANGE.start() - 1, Month::new(1).code(), 1, cal, @@ -373,7 +379,7 @@ super::test_all_cals!( .unwrap_err(); Date::try_new_from_codes( Some(&y.era), - *VALID_YEAR_RANGE.end() + 1, + *CONSTRUCTOR_YEAR_RANGE.end() + 1, Month::new(1).code(), 1, cal, @@ -381,14 +387,19 @@ super::test_all_cals!( .unwrap_err(); } - if let crate::types::YearInfo::Era(y) = - Date::try_new_from_codes(None, *VALID_YEAR_RANGE.end(), Month::new(1).code(), 1, cal) - .unwrap() - .year() + if let crate::types::YearInfo::Era(y) = Date::try_new_from_codes( + None, + *CONSTRUCTOR_YEAR_RANGE.end(), + Month::new(1).code(), + 1, + cal, + ) + .unwrap() + .year() { Date::try_new_from_codes( Some(&y.era), - *VALID_YEAR_RANGE.start() - 1, + *CONSTRUCTOR_YEAR_RANGE.start() - 1, Month::new(1).code(), 1, cal, @@ -396,7 +407,7 @@ super::test_all_cals!( .unwrap_err(); Date::try_new_from_codes( Some(&y.era), - *VALID_YEAR_RANGE.end() + 1, + *CONSTRUCTOR_YEAR_RANGE.end() + 1, Month::new(1).code(), 1, cal, @@ -415,54 +426,57 @@ mod check_convenience_constructors { use super::*; #[test] fn buddhist() { - Date::try_new_buddhist(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_buddhist(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_buddhist(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_buddhist(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] #[allow(deprecated)] fn chinese_traditional() { - Date::try_new_chinese_traditional(*VALID_YEAR_RANGE.start() - 1, Month::new(1), 1) + Date::try_new_chinese_traditional(*CONSTRUCTOR_YEAR_RANGE.start() - 1, Month::new(1), 1) .unwrap_err(); - Date::try_new_chinese_traditional(*VALID_YEAR_RANGE.end() + 1, Month::new(1), 1) + Date::try_new_chinese_traditional(*CONSTRUCTOR_YEAR_RANGE.end() + 1, Month::new(1), 1) .unwrap_err(); #[allow(deprecated)] { let c = ChineseTraditional::new(); - Date::try_new_chinese_with_calendar(*VALID_YEAR_RANGE.start() - 1, 1, 1, c) + Date::try_new_chinese_with_calendar(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, c) + .unwrap_err(); + Date::try_new_chinese_with_calendar(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, c) .unwrap_err(); - Date::try_new_chinese_with_calendar(*VALID_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); } } #[test] fn coptic() { - Date::try_new_coptic(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_coptic(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_coptic(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_coptic(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] fn korean_traditional() { - Date::try_new_korean_traditional(*VALID_YEAR_RANGE.start() - 1, Month::new(1), 1) + Date::try_new_korean_traditional(*CONSTRUCTOR_YEAR_RANGE.start() - 1, Month::new(1), 1) .unwrap_err(); - Date::try_new_korean_traditional(*VALID_YEAR_RANGE.end() + 1, Month::new(1), 1) + Date::try_new_korean_traditional(*CONSTRUCTOR_YEAR_RANGE.end() + 1, Month::new(1), 1) .unwrap_err(); #[allow(deprecated)] { let c = KoreanTraditional::new(); - Date::try_new_dangi_with_calendar(*VALID_YEAR_RANGE.start() - 1, 1, 1, c).unwrap_err(); - Date::try_new_dangi_with_calendar(*VALID_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); + Date::try_new_dangi_with_calendar(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, c) + .unwrap_err(); + Date::try_new_dangi_with_calendar(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, c) + .unwrap_err(); } } #[test] fn ethiopian() { Date::try_new_ethiopian( EthiopianEraStyle::AmeteMihret, - *VALID_YEAR_RANGE.start() - 1, + *CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, ) .unwrap_err(); Date::try_new_ethiopian( EthiopianEraStyle::AmeteMihret, - *VALID_YEAR_RANGE.end() + 1, + *CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, ) @@ -472,14 +486,14 @@ mod check_convenience_constructors { fn ethiopian_amete_alem() { Date::try_new_ethiopian( EthiopianEraStyle::AmeteAlem, - *VALID_YEAR_RANGE.start() - 1, + *CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, ) .unwrap_err(); Date::try_new_ethiopian( EthiopianEraStyle::AmeteAlem, - *VALID_YEAR_RANGE.end() + 1, + *CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, ) @@ -487,68 +501,77 @@ mod check_convenience_constructors { } #[test] fn gregorian() { - Date::try_new_gregorian(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_gregorian(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_gregorian(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_gregorian(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] fn hebrew() { - Date::try_new_hebrew_v2(*VALID_YEAR_RANGE.start() - 1, Month::new(1), 1).unwrap_err(); - Date::try_new_hebrew_v2(*VALID_YEAR_RANGE.end() + 1, Month::new(1), 1).unwrap_err(); + Date::try_new_hebrew_v2(*CONSTRUCTOR_YEAR_RANGE.start() - 1, Month::new(1), 1).unwrap_err(); + Date::try_new_hebrew_v2(*CONSTRUCTOR_YEAR_RANGE.end() + 1, Month::new(1), 1).unwrap_err(); #[allow(deprecated)] { - Date::try_new_hebrew(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_hebrew(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_hebrew(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_hebrew(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } } #[test] fn hijri_tabular_friday() { let c = Hijri::new_tabular(HijriTabularLeapYears::TypeII, HijriTabularEpoch::Friday); - Date::try_new_hijri_with_calendar(*VALID_YEAR_RANGE.start() - 1, 1, 1, c).unwrap_err(); - Date::try_new_hijri_with_calendar(*VALID_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); + Date::try_new_hijri_with_calendar(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, c) + .unwrap_err(); + Date::try_new_hijri_with_calendar(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); } #[test] fn hijri_tabular_thursday() { let c = Hijri::new_tabular(HijriTabularLeapYears::TypeII, HijriTabularEpoch::Thursday); - Date::try_new_hijri_with_calendar(*VALID_YEAR_RANGE.start() - 1, 1, 1, c).unwrap_err(); - Date::try_new_hijri_with_calendar(*VALID_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); + Date::try_new_hijri_with_calendar(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, c) + .unwrap_err(); + Date::try_new_hijri_with_calendar(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); } #[test] fn hijri_uaq() { let c = Hijri::new_umm_al_qura(); - Date::try_new_hijri_with_calendar(*VALID_YEAR_RANGE.start() - 1, 1, 1, c).unwrap_err(); - Date::try_new_hijri_with_calendar(*VALID_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); + Date::try_new_hijri_with_calendar(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1, c) + .unwrap_err(); + Date::try_new_hijri_with_calendar(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, c).unwrap_err(); } #[test] fn indian() { - Date::try_new_indian(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_indian(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_indian(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_indian(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] fn iso() { - Date::try_new_iso(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_iso(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_iso(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_iso(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] fn julian() { - Date::try_new_julian(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_julian(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_julian(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_julian(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] fn japanese() { let cal = Japanese::new(); - Date::try_new_japanese_with_calendar("reiwa", *VALID_YEAR_RANGE.start() - 1, 1, 1, cal) - .unwrap_err(); - Date::try_new_japanese_with_calendar("reiwa", *VALID_YEAR_RANGE.end() + 1, 1, 1, cal) + Date::try_new_japanese_with_calendar( + "reiwa", + *CONSTRUCTOR_YEAR_RANGE.start() - 1, + 1, + 1, + cal, + ) + .unwrap_err(); + Date::try_new_japanese_with_calendar("reiwa", *CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1, cal) .unwrap_err(); } #[test] fn persian() { - Date::try_new_persian(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_persian(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_persian(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_persian(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } #[test] fn roc() { - Date::try_new_roc(*VALID_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); - Date::try_new_roc(*VALID_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); + Date::try_new_roc(*CONSTRUCTOR_YEAR_RANGE.start() - 1, 1, 1).unwrap_err(); + Date::try_new_roc(*CONSTRUCTOR_YEAR_RANGE.end() + 1, 1, 1).unwrap_err(); } } From c9afc3c99b51ab4e1920c26dee8ba1438ef202e0 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 10:39:24 -0800 Subject: [PATCH 11/18] Have new_balanced return an unchecked arithmetic date --- .../calendar/src/calendar_arithmetic.rs | 83 +++++++++++-------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index feee66ba015..4bab260e371 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -101,6 +101,29 @@ impl Hash for ArithmeticDate { } } +/// Same data as ArithmeticDate, but may be out of [`VALID_RD_RANGE`] +#[derive(Debug)] +pub(crate) struct UncheckedArithmeticDate { + year: C::YearInfo, + ordinal_month: u8, + day: u8, +} + +impl UncheckedArithmeticDate { + pub(crate) fn to_checked(self) -> Result, YearOverflowError> { + let rd = C::to_rata_die_inner(self.year, self.ordinal_month, self.day); + if !VALID_RD_RANGE.contains(&rd) { + return Err(YearOverflowError); + } + // Invariant checked above + Ok(ArithmeticDate::new_unchecked( + self.year, + self.ordinal_month, + self.day, + )) + } +} + /// Maximum number of iterations when iterating through the days of a month; can be increased if necessary #[allow(dead_code)] // TODO: Remove dead code tag after use pub(crate) const MAX_ITERS_FOR_DAYS_OF_MONTH: u8 = 33; @@ -257,10 +280,9 @@ impl ArithmeticDate { C::YearInfo::unpack_day(self.0) } - /// Precondition: the date is in `GENEROUS_YEAR_RANGE`. + /// Precondition: the date is in `VALID_RD_RANGE`. /// - /// If giving this date to *users*, it must also be in `VALID_RD_RANGE`. `GENEROUS_YEAR_RANGE` - /// is fine to use for intermediate dates. + /// Use `UncheckedArithmeticDate` if you wish to generate intermediate out-of-range dates. #[inline] pub(crate) fn new_unchecked(year: C::YearInfo, month: u8, day: u8) -> Self { ArithmeticDate(C::YearInfo::pack(year, month, day)) @@ -530,7 +552,7 @@ impl ArithmeticDate { ordinal_month: i64, day: i64, cal: &C, - ) -> Result { + ) -> Result, YearOverflowError> { // 1. Let _resolvedYear_ be _arithmeticYear_. // 1. Let _resolvedMonth_ be _ordinalMonth_. let mut resolved_year = year; @@ -604,13 +626,11 @@ impl ArithmeticDate { debug_assert!(u8::try_from(resolved_day).is_ok()); let resolved_day = resolved_day as u8; // 1. Return the Record { [[Year]]: _resolvedYear_, [[Month]]: _resolvedMonth_, [[Day]]: _resolvedDay_ }. - Ok(Self::new_unchecked( - // We can be sure this is within `GENEROUS_YEAR_RANGE` since - // we have consistently used year_info_from_extended_checked. - resolved_year, - resolved_month, - resolved_day, - )) + Ok(UncheckedArithmeticDate { + year: resolved_year, + ordinal_month: resolved_month, + day: resolved_day, + }) } /// Implements the Temporal abstract operation `CompareSurpasses` based on month code @@ -746,8 +766,8 @@ impl ArithmeticDate { // 1. If CompareSurpasses(_sign_, _monthsAdded_.[[Year]], _monthsAdded_.[[Month]], _parts_.[[Day]], _calDate2_) is *true*, return *true*. if Self::compare_surpasses_ordinal( sign, - months_added.year(), - months_added.month(), + months_added.year, + months_added.ordinal_month, parts.day(), cal_date_2, ) { @@ -759,8 +779,8 @@ impl ArithmeticDate { } // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _monthsAdded_.[[Year]], _monthsAdded_.[[Month]] + 1, 0). let Ok(end_of_month) = Self::new_balanced( - months_added.year(), - i64::from(months_added.month()) + 1, + months_added.year, + i64::from(months_added.ordinal_month) + 1, 0, cal, ) else { @@ -772,17 +792,17 @@ impl ArithmeticDate { // 1. Let _regulatedDay_ be _baseDay_. // 1. Else, // 1. Let _regulatedDay_ be _endOfMonth_.[[Day]]. - let regulated_day = if base_day < end_of_month.day() { + let regulated_day = if base_day < end_of_month.day { base_day } else { - end_of_month.day() + end_of_month.day }; // 1. Let _daysInWeek_ be 7 (the number of days in a week for all supported calendars). // 1. Let _balancedDate_ be BalanceNonISODate(_calendar_, _endOfMonth_.[[Year]], _endOfMonth_.[[Month]], _regulatedDay_ + _daysInWeek_ * _weeks_ + _days_). // 1. Return CompareSurpasses(_sign_, _balancedDate_.[[Year]], _balancedDate_.[[Month]], _balancedDate_.[[Day]], _calDate2_). let Ok(balanced_date) = Self::new_balanced( - end_of_month.year(), - i64::from(end_of_month.month()), + end_of_month.year, + i64::from(end_of_month.ordinal_month), duration.add_weeks_and_days_to(regulated_day), cal, ) else { @@ -791,9 +811,9 @@ impl ArithmeticDate { Self::compare_surpasses_ordinal( sign, - balanced_date.year(), - balanced_date.month(), - balanced_date.day(), + balanced_date.year, + balanced_date.ordinal_month, + balanced_date.day, cal_date_2, ) } @@ -858,7 +878,7 @@ impl ArithmeticDate { let base_day = self.day(); // 1. If _baseDay_ ≤ _endOfMonth_.[[Day]], then // 1. Let _regulatedDay_ be _baseDay_. - let regulated_day = if base_day <= end_of_month.day() { + let regulated_day = if base_day <= end_of_month.day { base_day } else { // 1. Else, @@ -866,30 +886,23 @@ impl ArithmeticDate { // Note: ICU4X default is constrain here if matches!(options.overflow, Some(Overflow::Reject)) { return Err(DateAddError::InvalidDay { - max: end_of_month.day(), + max: end_of_month.day, }); } - end_of_month.day() + end_of_month.day }; // 1. Let _balancedDate_ be BalanceNonISODate(_calendar_, _endOfMonth_.[[Year]], _endOfMonth_.[[Month]], _regulatedDay_ + 7 * _duration_.[[Weeks]] + _duration_.[[Days]]). // 1. Let _result_ be ? CalendarIntegersToISO(_calendar_, _balancedDate_.[[Year]], _balancedDate_.[[Month]], _balancedDate_.[[Day]]). // 1. Return _result_. let balanced = Self::new_balanced( - end_of_month.year(), - i64::from(end_of_month.month()), + end_of_month.year, + i64::from(end_of_month.ordinal_month), duration.add_weeks_and_days_to(regulated_day), cal, )?; - // We early checked for a generous range of years, and `new_balanced` - // ensures the date stays within the generous range, now we must check - // to ensure we are actually in range for our core invariant before - // returning a date that is not internal to this API. - if !VALID_RD_RANGE.contains(&balanced.to_rata_die()) { - return Err(DateAddError::Overflow); - } - Ok(balanced) + Ok(balanced.to_checked()?) } /// Implements the Temporal abstract operation `NonISODateUntil`. From 542635ec17205d51b272e669afafbfb3b7e5008e Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 11:12:27 -0800 Subject: [PATCH 12/18] address comments --- .../calendar/src/calendar_arithmetic.rs | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 4bab260e371..772dd182491 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -18,12 +18,12 @@ use core::fmt::Debug; use core::hash::{Hash, Hasher}; use core::ops::RangeInclusive; -/// This is checked by constructors. Internally we don't care about this invariant. +/// This is checked by convenience constructors and `from_codes`. +/// Internally we don't care about this invariant. pub const CONSTRUCTOR_YEAR_RANGE: RangeInclusive = -9999..=9999; /// This is a fundamental invariant of `ArithmeticDate` and by extension all our -/// date types. Because this range exceeds the [`CONSTRUCTOR_YEAR_RANGE`], only -/// the valid year range is checked in most constructors. +/// date types. Constructors that don't check [`CONSTRUCTOR_YEAR_RANGE`] check this range. /// /// This is the range used by `Date::from_rata_die`, `Date::try_from_fields`, /// and Date arithmetic operations. @@ -103,14 +103,14 @@ impl Hash for ArithmeticDate { /// Same data as ArithmeticDate, but may be out of [`VALID_RD_RANGE`] #[derive(Debug)] -pub(crate) struct UncheckedArithmeticDate { +struct UncheckedArithmeticDate { year: C::YearInfo, ordinal_month: u8, day: u8, } impl UncheckedArithmeticDate { - pub(crate) fn to_checked(self) -> Result, YearOverflowError> { + fn to_checked(self) -> Result, YearOverflowError> { let rd = C::to_rata_die_inner(self.year, self.ordinal_month, self.day); if !VALID_RD_RANGE.contains(&rd) { return Err(YearOverflowError); @@ -183,8 +183,7 @@ pub(crate) trait DateFieldsResolver: Calendar { /// Converts the era and era year to a [`Self::YearInfo`]. If the calendar does not have eras, /// this should always return an Err result. /// - /// This function does not guard against overflow, please range-check input years - /// against [`CONSTRUCTOR_YEAR_RANGE`] or [`GENEROUS_YEAR_RANGE`] before passing to it. + /// Precondition: `era_year` is in [`GENEROUS_YEAR_RANGE`]. fn extended_year_from_era_year( &self, era: &[u8], @@ -192,13 +191,27 @@ pub(crate) trait DateFieldsResolver: Calendar { ) -> Result; /// Converts an extended year to a [`Self::YearInfo`]. + /// + /// Precondition: `extended_year` is in [`GENEROUS_YEAR_RANGE`]. + /// + /// Some calendars with complex calculations use this precondition to + /// ensure that the year is in a range where their arithmetic code is well-behaved. + /// + /// Calendar implementors who need this are encouraged to debug assert that the invariant + /// is upheld. + /// + /// This should primarily be used in constructors that range-check with + /// the very narrow [`CONSTRUCTOR_YEAR_RANGE`], after that check. If the + /// constructor checks [`VALID_RD_RANGE`], please use `year_info_from_extended_checked`. fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo; - /// `year_info_from_extended` will debug assert if given a too-large year - /// value. Most constructors range check for much smaller ranges, - /// but operations that only enforce the `VALID_RD_RANGE` should - /// be careful what they feed to it. They can use this checked version - /// instead. + /// `year_info_from_extended` has a precondition of the year being within + /// [`GENEROUS_YEAR_RANGE`]. + /// + /// Most constructors range check for much smaller ranges and don't need to + /// bother explicitly checking that, but operations that only enforce the + /// [`VALID_RD_RANGE`] should be careful what they feed to it. They can use + /// this checked version instead. fn year_info_from_extended_checked( &self, extended_year: i32, From 7e619e6c1d0741c54777b3407cdcf1efc83c8197 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 13:09:53 -0800 Subject: [PATCH 13/18] document tests --- components/calendar/src/error.rs | 67 +++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index 83ae04c4689..3e338339239 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -478,19 +478,82 @@ mod unstable { #[non_exhaustive] pub enum DateAddError { /// The day is invalid for the given month. + /// + /// This is only possible with [`Overflow::Reject`](crate::options::Overflow::Reject). + /// + /// # Examples + /// + /// ``` + /// use icu::calendar::Date; + /// use icu::calendar::error::DateAddError; + /// use icu::calendar::options::{DateAddOptions, Overflow}; + /// use icu::calendar::types::DateDuration; + /// + /// // There is a day 31 in October but not in November. + /// let d = Date::try_new_iso(2025, 10, 31).unwrap(); + /// let duration = DateDuration::for_months(1); + /// + /// let mut options = DateAddOptions::default(); + /// options.overflow = Some(Overflow::Reject); + /// + /// let err = d + /// .try_added_with_options(duration, options) + /// .expect_err("no day 31 in November"); + /// + /// assert!(matches!(err, DateAddError::InvalidDay { max: 30 })); + /// ``` #[displaydoc("Invalid day for month, max is {max}")] InvalidDay { /// The maximum allowed value (the minimum is 1). - /// - /// This is only possible with [`Overflow::Reject`](crate::options::Overflow::Reject). max: u8, }, /// The specified month exists in this calendar, but not in the specified year. /// /// This is only possible with [`Overflow::Reject`](crate::options::Overflow::Reject). + /// + /// # Examples + /// + /// ``` + /// use icu::calendar::cal::Hebrew; + /// use icu::calendar::types::{DateDuration, Month}; + /// use icu::calendar::Date; + /// use icu::calendar::error::DateAddError; + /// use icu::calendar::options::{DateAddOptions, Overflow}; + /// + /// // Hebrew year 5784 is a leap year, 5785 is not. + /// // Adar I (the leap month) is month 5 in a leap year. + /// let d = Date::try_new_hebrew_v2(5784, Month::leap(5), 1).unwrap(); + /// let duration = DateDuration::for_years(1); + /// + /// let mut options = DateAddOptions::default(); + /// options.overflow = Some(Overflow::Reject); + /// + /// let err = d + /// .try_added_with_options(duration, options) + /// .expect_err("5785 is not a leap year"); + /// + /// assert_eq!(err, DateAddError::MonthNotInYear); + /// ``` #[displaydoc("The specified month exists in this calendar, but not for this year")] MonthNotInYear, /// The date is out of range. + /// + /// # Examples + /// + /// ``` + /// use icu::calendar::Date; + /// use icu::calendar::error::DateAddError; + /// use icu::calendar::types::DateDuration; + /// + /// let d = Date::try_new_iso(2025, 1, 1).unwrap(); + /// let duration = DateDuration::for_years(1_000_000); + /// + /// let err = d + /// .try_added_with_options(duration, Default::default()) + /// .expect_err("date overflow"); + /// + /// assert_eq!(err, DateAddError::Overflow); + /// ``` #[displaydoc("Result out of range")] Overflow, } From b50965cb22e7714ab71465db32439b6034835ea7 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 13:25:04 -0800 Subject: [PATCH 14/18] fix --- components/calendar/src/calendar_arithmetic.rs | 2 +- ffi/capi/tests/missing_apis.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 772dd182491..a9bee4ca490 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -560,7 +560,7 @@ impl ArithmeticDate { /// /// This does *not* necessarily produce something within [`VALID_RD_RANGE`], but it will ensure things /// are within [`GENEROUS_YEAR_RANGE`]. - pub(crate) fn new_balanced( + fn new_balanced( year: C::YearInfo, ordinal_month: i64, day: i64, diff --git a/ffi/capi/tests/missing_apis.txt b/ffi/capi/tests/missing_apis.txt index 7acbb45d04b..5db8e16d18f 100644 --- a/ffi/capi/tests/missing_apis.txt +++ b/ffi/capi/tests/missing_apis.txt @@ -17,7 +17,7 @@ icu::calendar::Date::try_add_with_options#FnInStruct icu::calendar::Date::try_added_with_options#FnInStruct icu::calendar::Date::try_until_with_options#FnInStruct -+icu::calendar::error::DateAddError#Enum +icu::calendar::error::DateAddError#Enum icu::calendar::error::DateDurationParseError#Enum icu::calendar::error::EcmaReferenceYearError#Enum icu::calendar::options::DateAddOptions#Struct From 867db09b8b3130569e8e780d59abf60c856ca011 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 13:34:38 -0800 Subject: [PATCH 15/18] clippy --- components/calendar/src/calendar_arithmetic.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index a9bee4ca490..e8f680975eb 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -101,7 +101,7 @@ impl Hash for ArithmeticDate { } } -/// Same data as ArithmeticDate, but may be out of [`VALID_RD_RANGE`] +/// Same data as [`ArithmeticDate`], but may be out of [`VALID_RD_RANGE`] #[derive(Debug)] struct UncheckedArithmeticDate { year: C::YearInfo, @@ -110,7 +110,7 @@ struct UncheckedArithmeticDate { } impl UncheckedArithmeticDate { - fn to_checked(self) -> Result, YearOverflowError> { + fn into_checked(self) -> Result, YearOverflowError> { let rd = C::to_rata_die_inner(self.year, self.ordinal_month, self.day); if !VALID_RD_RANGE.contains(&rd) { return Err(YearOverflowError); @@ -915,7 +915,7 @@ impl ArithmeticDate { cal, )?; - Ok(balanced.to_checked()?) + Ok(balanced.into_checked()?) } /// Implements the Temporal abstract operation `NonISODateUntil`. @@ -951,6 +951,7 @@ impl ArithmeticDate { Ordering::Equal => return DateDuration::default(), Ordering::Less => -1i64, }; + // 1. Let _years_ be 0. // 1. If _largestUnit_ is ~year~, then // 1. Let _candidateYears_ be _sign_. From 5f32e15c2cc44026f5996f32a248aca9baca1d47 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 15:14:41 -0800 Subject: [PATCH 16/18] unchecked --- components/calendar/src/cal/abstract_gregorian.rs | 2 +- components/calendar/src/cal/coptic.rs | 2 +- .../calendar/src/cal/east_asian_traditional.rs | 2 +- components/calendar/src/cal/ethiopian.rs | 2 +- components/calendar/src/cal/hebrew.rs | 2 +- components/calendar/src/cal/hijri.rs | 2 +- components/calendar/src/cal/indian.rs | 2 +- components/calendar/src/cal/julian.rs | 2 +- components/calendar/src/cal/persian.rs | 2 +- components/calendar/src/calendar_arithmetic.rs | 15 ++++++++------- components/calendar/src/tests/extrema.rs | 10 +++++----- 11 files changed, 22 insertions(+), 21 deletions(-) diff --git a/components/calendar/src/cal/abstract_gregorian.rs b/components/calendar/src/cal/abstract_gregorian.rs index 41b7befc361..1ba55cc9a89 100644 --- a/components/calendar/src/cal/abstract_gregorian.rs +++ b/components/calendar/src/cal/abstract_gregorian.rs @@ -52,7 +52,7 @@ impl DateFieldsResolver for AbstractGregorian { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/coptic.rs b/components/calendar/src/cal/coptic.rs index 6e501da28a4..a6d57779d0c 100644 --- a/components/calendar/src/cal/coptic.rs +++ b/components/calendar/src/cal/coptic.rs @@ -68,7 +68,7 @@ impl DateFieldsResolver for Coptic { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/east_asian_traditional.rs b/components/calendar/src/cal/east_asian_traditional.rs index 73e5b8bad5e..d5e320f2bbe 100644 --- a/components/calendar/src/cal/east_asian_traditional.rs +++ b/components/calendar/src/cal/east_asian_traditional.rs @@ -588,7 +588,7 @@ impl DateFieldsResolver for EastAsianTraditional { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, _era: &[u8], _era_year: i32, diff --git a/components/calendar/src/cal/ethiopian.rs b/components/calendar/src/cal/ethiopian.rs index edcb819a720..baa409f496d 100644 --- a/components/calendar/src/cal/ethiopian.rs +++ b/components/calendar/src/cal/ethiopian.rs @@ -95,7 +95,7 @@ impl DateFieldsResolver for Ethiopian { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/hebrew.rs b/components/calendar/src/cal/hebrew.rs index 1dd2cc3f25a..d4b19df3ac4 100644 --- a/components/calendar/src/cal/hebrew.rs +++ b/components/calendar/src/cal/hebrew.rs @@ -135,7 +135,7 @@ impl DateFieldsResolver for Hebrew { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index 8010e805c9a..dd7088ade21 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -846,7 +846,7 @@ impl DateFieldsResolver for Hijri { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/indian.rs b/components/calendar/src/cal/indian.rs index 89515855922..2252ac1b78d 100644 --- a/components/calendar/src/cal/indian.rs +++ b/components/calendar/src/cal/indian.rs @@ -71,7 +71,7 @@ impl DateFieldsResolver for Indian { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/julian.rs b/components/calendar/src/cal/julian.rs index 52f6d2fa88d..77fbaa00061 100644 --- a/components/calendar/src/cal/julian.rs +++ b/components/calendar/src/cal/julian.rs @@ -93,7 +93,7 @@ impl DateFieldsResolver for Julian { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/cal/persian.rs b/components/calendar/src/cal/persian.rs index f85269905f2..71c4c37b122 100644 --- a/components/calendar/src/cal/persian.rs +++ b/components/calendar/src/cal/persian.rs @@ -65,7 +65,7 @@ impl DateFieldsResolver for Persian { } #[inline] - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index e8f680975eb..48ee8ce6f64 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -20,14 +20,14 @@ use core::ops::RangeInclusive; /// This is checked by convenience constructors and `from_codes`. /// Internally we don't care about this invariant. -pub const CONSTRUCTOR_YEAR_RANGE: RangeInclusive = -9999..=9999; +pub(crate) const CONSTRUCTOR_YEAR_RANGE: RangeInclusive = -9999..=9999; /// This is a fundamental invariant of `ArithmeticDate` and by extension all our /// date types. Constructors that don't check [`CONSTRUCTOR_YEAR_RANGE`] check this range. /// /// This is the range used by `Date::from_rata_die`, `Date::try_from_fields`, /// and Date arithmetic operations. -pub const VALID_RD_RANGE: RangeInclusive = +pub(crate) const VALID_RD_RANGE: RangeInclusive = calendrical_calculations::gregorian::fixed_from_gregorian(-999999, 1, 1) ..=calendrical_calculations::gregorian::fixed_from_gregorian(999999, 12, 31); @@ -49,7 +49,7 @@ pub const VALID_RD_RANGE: RangeInclusive = /// /// The tests in `extrema.rs` ensure that all in-range dates can be produced by the APIs that, /// use this check, and that these year numbers map to out-of-range values for every era. -pub const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_000; +pub(crate) const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_000; // Invariant: VALID_RD_RANGE contains the date #[derive(Debug)] @@ -184,7 +184,7 @@ pub(crate) trait DateFieldsResolver: Calendar { /// this should always return an Err result. /// /// Precondition: `era_year` is in [`GENEROUS_YEAR_RANGE`]. - fn extended_year_from_era_year( + fn extended_year_from_era_year_unchecked( &self, era: &[u8], era_year: i32, @@ -314,7 +314,7 @@ impl ArithmeticDate { calendar: &C, ) -> Result { let extended_year = if let Some(era) = era { - calendar.extended_year_from_era_year( + calendar.extended_year_from_era_year_unchecked( era.as_bytes(), range_check(year, "era_year", CONSTRUCTOR_YEAR_RANGE)?, )? @@ -435,7 +435,8 @@ impl ArithmeticDate { if !GENEROUS_YEAR_RANGE.contains(&era_year) { return Err(DateFromFieldsError::Overflow); } - let extended_year = calendar.extended_year_from_era_year(era, era_year)?; + let extended_year = + calendar.extended_year_from_era_year_unchecked(era, era_year)?; let year = calendar.year_info_from_extended_checked(extended_year)?; if let Some(extended_year) = fields.extended_year { if year.to_extended_year() != extended_year { @@ -538,7 +539,7 @@ impl ArithmeticDate { day: u8, cal: &C, ) -> Result { - let extended_year = cal.extended_year_from_era_year( + let extended_year = cal.extended_year_from_era_year_unchecked( era.as_bytes(), range_check(year, "era_year", CONSTRUCTOR_YEAR_RANGE)?, )?; diff --git a/components/calendar/src/tests/extrema.rs b/components/calendar/src/tests/extrema.rs index 903b3c3947c..87be22e1786 100644 --- a/components/calendar/src/tests/extrema.rs +++ b/components/calendar/src/tests/extrema.rs @@ -86,10 +86,10 @@ super::test_all_cals!( RataDie::new(-1000), RataDie::new(0), RataDie::new(1000), - VALID_RD_RANGE.end().add(10000), - VALID_RD_RANGE.end().add(100), - VALID_RD_RANGE.end().add(5), - VALID_RD_RANGE.end().add(1), + VALID_RD_RANGE.end().add(-10000), + VALID_RD_RANGE.end().add(-100), + VALID_RD_RANGE.end().add(-5), + VALID_RD_RANGE.end().add(-1), *VALID_RD_RANGE.end(), ]; @@ -188,7 +188,7 @@ super::test_all_cals!( // First we want to test that large values all get range checked for era in [first_era, last_era, None] { // We want to ensure that the "early" generous year range check - // AND the + // AND the RD check both run but return the same errors. for year in [ *GENEROUS_YEAR_RANGE.start() - 1, *GENEROUS_YEAR_RANGE.start(), From 6994df0e782c4bd33c88b3a9f288c008154e7aa2 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 15:29:43 -0800 Subject: [PATCH 17/18] Reintroduce SAFE_YEAR_RANGE --- .../src/cal/east_asian_traditional.rs | 2 +- components/calendar/src/cal/hijri.rs | 2 +- .../calendar/src/calendar_arithmetic.rs | 54 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/components/calendar/src/cal/east_asian_traditional.rs b/components/calendar/src/cal/east_asian_traditional.rs index d5e320f2bbe..8a2c68dca51 100644 --- a/components/calendar/src/cal/east_asian_traditional.rs +++ b/components/calendar/src/cal/east_asian_traditional.rs @@ -599,7 +599,7 @@ impl DateFieldsResolver for EastAsianTraditional { #[inline] fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo { - debug_assert!(crate::calendar_arithmetic::GENEROUS_YEAR_RANGE.contains(&extended_year)); + debug_assert!(crate::calendar_arithmetic::SAFE_YEAR_RANGE.contains(&extended_year)); self.0.year(extended_year) } diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index dd7088ade21..4433d21c344 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -861,7 +861,7 @@ impl DateFieldsResolver for Hijri { #[inline] fn year_info_from_extended(&self, extended_year: i32) -> Self::YearInfo { - debug_assert!(crate::calendar_arithmetic::GENEROUS_YEAR_RANGE.contains(&extended_year)); + debug_assert!(crate::calendar_arithmetic::SAFE_YEAR_RANGE.contains(&extended_year)); self.0.year(extended_year) } diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 48ee8ce6f64..a4338bf66c3 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -51,6 +51,17 @@ pub(crate) const VALID_RD_RANGE: RangeInclusive = /// use this check, and that these year numbers map to out-of-range values for every era. pub(crate) const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_000; +/// This is the year range all calendar specific computation can assume their data to be in. +/// +/// It is wider than [`GENEROUS_YEAR_RANGE`]: `GENEROUS_YEAR_RANGE` is used for checking user +/// input, this is more of an internal invariant for assertions. New calendar implementations +/// must ensure they do not have overflows or panics for year (era year or extended year) within this range. +/// +/// This value is computed by going past the max value producible by adding a duration, +/// which is preemptively capped at `GENEROUS_MAX_YEARS`, `GENEROUS_MAX_MONTHS, `GENEROUS_MAX_DAYS` +/// in `added()`. +pub(crate) const SAFE_YEAR_RANGE: RangeInclusive = -8_000_000..=8_000_000; + // Invariant: VALID_RD_RANGE contains the date #[derive(Debug)] pub(crate) struct ArithmeticDate(::Packed); @@ -183,7 +194,7 @@ pub(crate) trait DateFieldsResolver: Calendar { /// Converts the era and era year to a [`Self::YearInfo`]. If the calendar does not have eras, /// this should always return an Err result. /// - /// Precondition: `era_year` is in [`GENEROUS_YEAR_RANGE`]. + /// Precondition: `era_year` is in [`SAFE_YEAR_RANGE`]. fn extended_year_from_era_year_unchecked( &self, era: &[u8], @@ -566,7 +577,7 @@ impl ArithmeticDate { ordinal_month: i64, day: i64, cal: &C, - ) -> Result, YearOverflowError> { + ) -> UncheckedArithmeticDate { // 1. Let _resolvedYear_ be _arithmeticYear_. // 1. Let _resolvedMonth_ be _ordinalMonth_. let mut resolved_year = year; @@ -578,8 +589,7 @@ impl ArithmeticDate { // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). // 1. Set _resolvedMonth_ to _resolvedMonth_ + _monthsInYear_. while resolved_month <= 0 { - resolved_year = - cal.year_info_from_extended_checked(resolved_year.to_extended_year() - 1)?; + resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() - 1); months_in_year = C::months_in_provided_year(resolved_year); resolved_month += i64::from(months_in_year); } @@ -589,8 +599,7 @@ impl ArithmeticDate { // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). while resolved_month > i64::from(months_in_year) { resolved_month -= i64::from(months_in_year); - resolved_year = - cal.year_info_from_extended_checked(resolved_year.to_extended_year() + 1)?; + resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() + 1); months_in_year = C::months_in_provided_year(resolved_year); } debug_assert!(u8::try_from(resolved_month).is_ok()); @@ -608,8 +617,7 @@ impl ArithmeticDate { // 1. Set _resolvedYear_ to _resolvedYear_ - 1. // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). // 1. Set _resolvedMonth_ to _monthsInYear_. - resolved_year = - cal.year_info_from_extended_checked(resolved_year.to_extended_year() - 1)?; + resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() - 1); months_in_year = C::months_in_provided_year(resolved_year); resolved_month = months_in_year; } @@ -629,8 +637,7 @@ impl ArithmeticDate { // 1. Set _resolvedYear_ to _resolvedYear_ + 1. // 1. Set _monthsInYear_ to CalendarMonthsInYear(_calendar_, _resolvedYear_). // 1. Set _resolvedMonth_ to 1. - resolved_year = - cal.year_info_from_extended_checked(resolved_year.to_extended_year() + 1)?; + resolved_year = cal.year_info_from_extended(resolved_year.to_extended_year() + 1); months_in_year = C::months_in_provided_year(resolved_year); resolved_month = 1; } @@ -640,11 +647,11 @@ impl ArithmeticDate { debug_assert!(u8::try_from(resolved_day).is_ok()); let resolved_day = resolved_day as u8; // 1. Return the Record { [[Year]]: _resolvedYear_, [[Month]]: _resolvedMonth_, [[Day]]: _resolvedDay_ }. - Ok(UncheckedArithmeticDate { + UncheckedArithmeticDate { year: resolved_year, ordinal_month: resolved_month, day: resolved_day, - }) + } } /// Implements the Temporal abstract operation `CompareSurpasses` based on month code @@ -772,11 +779,8 @@ impl ArithmeticDate { } }; // 1. Let _monthsAdded_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _months_, 1). - let Ok(months_added) = Self::new_balanced(y0, duration.add_months_to(m0), 1, cal) else { - // Any operation that brings us out of range will have surpassed any valid date input - // we might have received - return true; - }; + let months_added = Self::new_balanced(y0, duration.add_months_to(m0), 1, cal); + // 1. If CompareSurpasses(_sign_, _monthsAdded_.[[Year]], _monthsAdded_.[[Month]], _parts_.[[Day]], _calDate2_) is *true*, return *true*. if Self::compare_surpasses_ordinal( sign, @@ -792,14 +796,12 @@ impl ArithmeticDate { return false; } // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _monthsAdded_.[[Year]], _monthsAdded_.[[Month]] + 1, 0). - let Ok(end_of_month) = Self::new_balanced( + let end_of_month = Self::new_balanced( months_added.year, i64::from(months_added.ordinal_month) + 1, 0, cal, - ) else { - return true; - }; + ); // 1. Let _baseDay_ be _parts_.[[Day]]. let base_day = parts.day(); // 1. If _baseDay_ ≤ _endOfMonth_.[[Day]], then @@ -814,14 +816,12 @@ impl ArithmeticDate { // 1. Let _daysInWeek_ be 7 (the number of days in a week for all supported calendars). // 1. Let _balancedDate_ be BalanceNonISODate(_calendar_, _endOfMonth_.[[Year]], _endOfMonth_.[[Month]], _regulatedDay_ + _daysInWeek_ * _weeks_ + _days_). // 1. Return CompareSurpasses(_sign_, _balancedDate_.[[Year]], _balancedDate_.[[Month]], _balancedDate_.[[Day]], _calDate2_). - let Ok(balanced_date) = Self::new_balanced( + let balanced_date = Self::new_balanced( end_of_month.year, i64::from(end_of_month.ordinal_month), duration.add_weeks_and_days_to(regulated_day), cal, - ) else { - return true; - }; + ); Self::compare_surpasses_ordinal( sign, @@ -887,7 +887,7 @@ impl ArithmeticDate { Err(MonthError::NotInYear) => return Err(DateAddError::MonthNotInYear), }; // 1. Let _endOfMonth_ be BalanceNonISODate(_calendar_, _y0_, _m0_ + _duration_.[[Months]] + 1, 0). - let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal)?; + let end_of_month = Self::new_balanced(y0, duration.add_months_to(m0) + 1, 0, cal); // 1. Let _baseDay_ be _parts_.[[Day]]. let base_day = self.day(); // 1. If _baseDay_ ≤ _endOfMonth_.[[Day]], then @@ -914,7 +914,7 @@ impl ArithmeticDate { i64::from(end_of_month.ordinal_month), duration.add_weeks_and_days_to(regulated_day), cal, - )?; + ); Ok(balanced.into_checked()?) } From c4eb9d1b58ab2559feb18053facca9f1e8f75b3e Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 23 Feb 2026 16:03:57 -0800 Subject: [PATCH 18/18] fix --- components/calendar/src/calendar_arithmetic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index a4338bf66c3..0afaeddbd10 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -58,7 +58,7 @@ pub(crate) const GENEROUS_YEAR_RANGE: RangeInclusive = -1_040_000..=1_040_0 /// must ensure they do not have overflows or panics for year (era year or extended year) within this range. /// /// This value is computed by going past the max value producible by adding a duration, -/// which is preemptively capped at `GENEROUS_MAX_YEARS`, `GENEROUS_MAX_MONTHS, `GENEROUS_MAX_DAYS` +/// which is preemptively capped at `GENEROUS_MAX_YEARS`, `GENEROUS_MAX_MONTHS`, `GENEROUS_MAX_DAYS` /// in `added()`. pub(crate) const SAFE_YEAR_RANGE: RangeInclusive = -8_000_000..=8_000_000;