From b2ea43ef12f727f849fd0fdf0a16dc99a05fccb1 Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Wed, 15 Oct 2025 10:09:56 -0700 Subject: [PATCH 1/6] Create ValidMonthCode and use in MonthInfo --- components/calendar/src/cal/chinese.rs | 43 ++-------- components/calendar/src/cal/hebrew.rs | 50 +++-------- .../calendar/src/calendar_arithmetic.rs | 16 ++-- components/calendar/src/error.rs | 9 ++ components/calendar/src/types.rs | 84 +++++++++++++++++-- 5 files changed, 113 insertions(+), 89 deletions(-) diff --git a/components/calendar/src/cal/chinese.rs b/components/calendar/src/cal/chinese.rs index a96a324e40c..a0b5a50cbf5 100644 --- a/components/calendar/src/cal/chinese.rs +++ b/components/calendar/src/cal/chinese.rs @@ -13,7 +13,7 @@ use crate::error::{ use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, Overflow}; use crate::provider::chinese_based::PackedChineseBasedYearInfo; -use crate::types::{MonthCode, MonthInfo}; +use crate::types::{MonthCode, MonthInfo, ValidMonthCode}; use crate::AsCalendar; use crate::{types, Calendar, Date}; use calendrical_calculations::chinese_based::{ @@ -975,25 +975,11 @@ impl LunarChineseYearData { // ordinally `month 2`, zero-indexed) // 14 is a sentinel value let leap_month = self.leap_month().unwrap_or(14); - let code_inner = if leap_month == month { + let valid_month_code = if leap_month == month { // Month cannot be 1 because a year cannot have a leap month before the first actual month, // and the maximum num of months ina leap year is 13. debug_assert!((2..=13).contains(&month)); - match month { - 2 => tinystr!(4, "M01L"), - 3 => tinystr!(4, "M02L"), - 4 => tinystr!(4, "M03L"), - 5 => tinystr!(4, "M04L"), - 6 => tinystr!(4, "M05L"), - 7 => tinystr!(4, "M06L"), - 8 => tinystr!(4, "M07L"), - 9 => tinystr!(4, "M08L"), - 10 => tinystr!(4, "M09L"), - 11 => tinystr!(4, "M10L"), - 12 => tinystr!(4, "M11L"), - 13 => tinystr!(4, "M12L"), - _ => tinystr!(4, "und"), - } + ValidMonthCode::new_unchecked(month - 1, true) } else { let mut adjusted_ordinal = month; if month > leap_month { @@ -1005,27 +991,14 @@ impl LunarChineseYearData { adjusted_ordinal -= 1; } debug_assert!((1..=12).contains(&adjusted_ordinal)); - match adjusted_ordinal { - 1 => tinystr!(4, "M01"), - 2 => tinystr!(4, "M02"), - 3 => tinystr!(4, "M03"), - 4 => tinystr!(4, "M04"), - 5 => tinystr!(4, "M05"), - 6 => tinystr!(4, "M06"), - 7 => tinystr!(4, "M07"), - 8 => tinystr!(4, "M08"), - 9 => tinystr!(4, "M09"), - 10 => tinystr!(4, "M10"), - 11 => tinystr!(4, "M11"), - 12 => tinystr!(4, "M12"), - _ => tinystr!(4, "und"), - } + ValidMonthCode::new_unchecked(adjusted_ordinal, false) }; - let code = MonthCode(code_inner); + let month_code = valid_month_code.to_month_code(); MonthInfo { ordinal: month, - standard_code: code, - formatting_code: code, + valid_standard_code: valid_month_code, + standard_code: month_code, + formatting_code: month_code, } } diff --git a/components/calendar/src/cal/hebrew.rs b/components/calendar/src/cal/hebrew.rs index c0e7d3e712c..8c438928275 100644 --- a/components/calendar/src/cal/hebrew.rs +++ b/components/calendar/src/cal/hebrew.rs @@ -2,6 +2,8 @@ // called LICENSE at the top level of the ICU4X source tree // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). +use core::cmp::Ordering; + use crate::cal::iso::{Iso, IsoDateInner}; use crate::calendar_arithmetic::ArithmeticDateBuilder; use crate::calendar_arithmetic::{ @@ -12,7 +14,7 @@ use crate::error::{ }; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, Overflow}; -use crate::types::{DateFields, MonthInfo}; +use crate::types::{DateFields, MonthInfo, ValidMonthCode}; use crate::RangeError; use crate::{types, Calendar, Date}; use ::tinystr::tinystr; @@ -243,50 +245,20 @@ impl DateFieldsResolver for Hebrew { year: &Self::YearInfo, ordinal_month: u8, ) -> types::MonthInfo { - let mut ordinal = ordinal_month; let is_leap_year = Self::provided_year_is_leap(*year); - if is_leap_year { - if ordinal == 6 { - return types::MonthInfo { - ordinal, - standard_code: types::MonthCode(tinystr!(4, "M05L")), - formatting_code: types::MonthCode(tinystr!(4, "M05L")), - }; - } else if ordinal == 7 { - return types::MonthInfo { - ordinal, - // Adar II is the same as Adar and has the same code - standard_code: types::MonthCode(tinystr!(4, "M06")), - formatting_code: types::MonthCode(tinystr!(4, "M06L")), - }; - } - } - - if is_leap_year && ordinal > 6 { - ordinal -= 1; - } - - let code = match ordinal { - 1 => tinystr!(4, "M01"), - 2 => tinystr!(4, "M02"), - 3 => tinystr!(4, "M03"), - 4 => tinystr!(4, "M04"), - 5 => tinystr!(4, "M05"), - 6 => tinystr!(4, "M06"), - 7 => tinystr!(4, "M07"), - 8 => tinystr!(4, "M08"), - 9 => tinystr!(4, "M09"), - 10 => tinystr!(4, "M10"), - 11 => tinystr!(4, "M11"), - 12 => tinystr!(4, "M12"), - _ => tinystr!(4, "und"), + let valid_month_code = match (ordinal_month.cmp(&6), is_leap_year) { + (Ordering::Less, _) | (_, false) => ValidMonthCode::new_unchecked(ordinal_month, false), + (Ordering::Equal, true) => ValidMonthCode::new_unchecked(5, true), + (Ordering::Greater, true) => ValidMonthCode::new_unchecked(ordinal_month - 1, false) }; + let month_code = valid_month_code.to_month_code(); types::MonthInfo { ordinal: ordinal_month, - standard_code: types::MonthCode(code), - formatting_code: types::MonthCode(code), + valid_standard_code: valid_month_code, + standard_code: month_code, + formatting_code: month_code, } } } diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index 84ced09ac8b..e3241ff2241 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -8,7 +8,7 @@ use crate::error::{ }; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, MissingFieldsStrategy, Overflow}; -use crate::types::{DateDuration, DateDurationUnit, DateFields, DayOfYear, MonthCode}; +use crate::types::{DateDuration, DateDurationUnit, DateFields, DayOfYear, MonthCode, ValidMonthCode}; use crate::{types, Calendar, DateError, RangeError}; use core::cmp::Ordering; use core::convert::TryInto; @@ -198,17 +198,13 @@ pub(crate) trait DateFieldsResolver: Calendar { _year: &Self::YearInfo, ordinal_month: u8, ) -> types::MonthInfo { - let code = match MonthCode::new_normal(ordinal_month) { - Some(code) => code, - None => { - debug_assert!(false, "ordinal month out of range!"); - MonthCode(tinystr!(4, "und")) - } - }; + let valid_month_code = ValidMonthCode::new_unchecked(ordinal_month, false); + let month_code = valid_month_code.to_month_code(); types::MonthInfo { ordinal: ordinal_month, - standard_code: code, - formatting_code: code, + valid_standard_code: valid_month_code, + standard_code: month_code, + formatting_code: month_code, } } } diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index f51e6c8f95d..08736647b89 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -435,6 +435,15 @@ impl From for DateError { } } +/// An error when validating a month code. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Display)] +#[non_exhaustive] +pub enum MonthCodeParseError { + /// The month code had invalid syntax. + #[displaydoc("The month code had invalid syntax")] + InvalidSyntax, +} + pub(crate) fn range_check_with_overflow + Copy>( value: T, field: &'static str, diff --git a/components/calendar/src/types.rs b/components/calendar/src/types.rs index e79f6d89b20..bd79868f5cf 100644 --- a/components/calendar/src/types.rs +++ b/components/calendar/src/types.rs @@ -174,6 +174,8 @@ pub struct CyclicYear { pub struct MonthCode(pub TinyStr4); impl MonthCode { + pub(crate) const SENTINEL: MonthCode = MonthCode(tinystr::tinystr!(4, "und")); + /// Returns an option which is `Some` containing the non-month version of a leap month /// if the [`MonthCode`] this method is called upon is a leap month, and `None` otherwise. /// This method assumes the [`MonthCode`] is valid. @@ -186,24 +188,32 @@ impl MonthCode { } } /// Get the month number and whether or not it is leap from the month code + #[deprecated(since = "2.1", note = "use validated()")] pub fn parsed(self) -> Option<(u8, bool)> { - self.try_parse().ok() + let valid_month_code = self.validated().ok()?; + Some((valid_month_code.number(), valid_month_code.is_leap())) } - pub(crate) fn try_parse(self) -> Result<(u8, bool), MonthCodeParseError> { + + /// Validates the syntax and returns a [`ValidMonthCode`], from which the + /// month number and leap month status can be read. + pub fn validated(self) -> Result { // Match statements on tinystrs are annoying so instead // we calculate it from the bytes directly let bytes = self.0.all_bytes(); let is_leap = bytes[3] == b'L'; if bytes[0] != b'M' { - return Err(MonthCodeParseError::InvalidMonthCode); + return Err(MonthCodeParseError::InvalidSyntax); } let b1 = bytes[1]; let b2 = bytes[2]; if !b1.is_ascii_digit() || !b2.is_ascii_digit() { - return Err(MonthCodeParseError::InvalidMonthCode); + return Err(MonthCodeParseError::InvalidSyntax); } - Ok(((b1 - b'0') * 10 + b2 - b'0', is_leap)) + Ok(ValidMonthCode { + number: (b1 - b'0') * 10 + b2 - b'0', + is_leap + }) } /// Construct a "normal" month code given a number ("Mxx"). @@ -274,6 +284,67 @@ impl fmt::Display for MonthCode { } } +/// A [`MonthCode`] that has been parsed into its internal representation. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ValidMonthCode { + /// Month number between 0 and 99 + number: u8, + is_leap: bool, +} + +impl ValidMonthCode { + /// Create a new ValidMonthCode without checking that the number is between 0 and 99 + pub(crate) fn new_unchecked(number: u8, is_leap: bool) -> Self { + debug_assert!(number <= 99); + Self { + number, is_leap + } + } + + /// Returns the month number according to the month code. + /// + /// This is NOT the same as the ordinal month! + /// + /// # Examples + /// + /// ``` + /// use icu::calendar::Date; + /// use icu::calendar::cal::Hebrew; + /// + /// let hebrew_date = Date::try_new_iso(2024, 7, 1).unwrap().to_calendar(Hebrew); + /// let month_info = hebrew_date.month(); + /// + /// // Hebrew year 5784 was a leap year, so the ordinal month and month number diverge. + /// assert_eq!(month_info.ordinal, 10); + /// assert_eq!(month_info.valid_month_code.number(), 9); + /// ``` + pub fn number(self) -> u8 { + self.number + } + + /// Returns whether the month is a leap month. + /// + /// This is true for intercalary months in [`Hebrew`] and [`LunarChinese`]. + /// + /// [`Hebrew`]: crate::cal::Hebrew + /// [`LunarChinese`]: crate::cal::LunarChinese + pub fn is_leap(self) -> bool { + self.is_leap + } + + pub(crate) fn to_month_code(self) -> MonthCode { + let option = if self.is_leap { + MonthCode::new_leap(self.number) + } else { + MonthCode::new_normal(self.number) + }; + option.unwrap_or_else(|| { + debug_assert!(false, "ValidMonthCode invariants guarantee conversion to MonthCode"); + MonthCode::SENTINEL + }) + } +} + /// Representation of a formattable month. #[derive(Copy, Clone, Debug, PartialEq)] #[non_exhaustive] @@ -294,6 +365,9 @@ pub struct MonthInfo { /// /// [`Date::try_new_from_codes`]: crate::Date::try_new_from_codes /// [`Date::try_from_fields`]: crate::Date::try_from_fields + pub valid_standard_code: ValidMonthCode, + + /// Same as [`Self::valid_standard_code`] but without syntax invariants. pub standard_code: MonthCode, /// A month code, useable for formatting. From d7dce4ed087ff75e5543c418d01344080fe083b8 Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Wed, 15 Oct 2025 10:33:47 -0700 Subject: [PATCH 2/6] Migrations --- .../calendar/src/cal/abstract_gregorian.rs | 2 +- components/calendar/src/cal/chinese.rs | 34 ++++++++-------- components/calendar/src/cal/coptic.rs | 10 ++--- components/calendar/src/cal/ethiopian.rs | 6 +-- components/calendar/src/cal/hebrew.rs | 12 +++--- components/calendar/src/cal/hijri.rs | 16 ++++---- 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 | 36 ++++++++++------- components/calendar/src/error.rs | 32 ++++++++------- components/calendar/src/types.rs | 40 +++++++++++-------- components/datetime/src/pattern/names.rs | 4 +- 13 files changed, 111 insertions(+), 93 deletions(-) diff --git a/components/calendar/src/cal/abstract_gregorian.rs b/components/calendar/src/cal/abstract_gregorian.rs index 0967e4d1776..93e9f42e5b3 100644 --- a/components/calendar/src/cal/abstract_gregorian.rs +++ b/components/calendar/src/cal/abstract_gregorian.rs @@ -104,7 +104,7 @@ impl DateFieldsResolver for AbstractGregorian { #[inline] fn reference_year_from_month_day( &self, - _month_code: types::MonthCode, + _month_code: types::ValidMonthCode, _day: u8, ) -> Result { Ok(REFERENCE_YEAR) diff --git a/components/calendar/src/cal/chinese.rs b/components/calendar/src/cal/chinese.rs index a0b5a50cbf5..a416c93311f 100644 --- a/components/calendar/src/cal/chinese.rs +++ b/components/calendar/src/cal/chinese.rs @@ -13,7 +13,7 @@ use crate::error::{ use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, Overflow}; use crate::provider::chinese_based::PackedChineseBasedYearInfo; -use crate::types::{MonthCode, MonthInfo, ValidMonthCode}; +use crate::types::{MonthInfo, ValidMonthCode}; use crate::AsCalendar; use crate::{types, Calendar, Date}; use calendrical_calculations::chinese_based::{ @@ -22,7 +22,6 @@ use calendrical_calculations::chinese_based::{ use calendrical_calculations::rata_die::RataDie; use icu_locale_core::preferences::extensions::unicode::keywords::CalendarAlgorithm; use icu_provider::prelude::*; -use tinystr::tinystr; #[path = "chinese/china_data.rs"] mod china_data; @@ -122,7 +121,7 @@ pub trait Rules: Clone + core::fmt::Debug + crate::cal::scaffold::UnstableSealed /// [`MissingFieldsStrategy::Ecma`]: crate::options::MissingFieldsStrategy::Ecma fn ecma_reference_year( &self, - _month_code: types::MonthCode, + _month_code: types::ValidMonthCode, _day: u8, ) -> Result { Err(EcmaReferenceYearError::NotEnoughFields) @@ -205,10 +204,10 @@ impl Rules for China { fn ecma_reference_year( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (number, is_leap) = month_code.try_parse()?; + let (number, is_leap) = month_code.to_tuple(); // Computed by `generate_reference_years` Ok(match (number, is_leap, day > 29) { (1, false, false) => 1972, @@ -386,10 +385,10 @@ impl Rules for Korea { fn ecma_reference_year( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (number, is_leap) = month_code.try_parse()?; + let (number, is_leap) = month_code.to_tuple(); // Computed by `generate_reference_years` Ok(match (number, is_leap, day > 29) { (1, false, false) => 1972, @@ -577,7 +576,7 @@ impl DateFieldsResolver for LunarChinese { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { Ok(self @@ -588,7 +587,7 @@ impl DateFieldsResolver for LunarChinese { fn ordinal_month_from_code( &self, year: &Self::YearInfo, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, options: DateFromFieldsOptions, ) -> Result { match year.parse_month_code(month_code) { @@ -1029,12 +1028,12 @@ impl LunarChineseYearData { } /// Get the ordinal lunar month from a code for chinese-based calendars. - fn parse_month_code(self, code: MonthCode) -> ComputedOrdinalMonth { + fn parse_month_code(self, code: ValidMonthCode) -> ComputedOrdinalMonth { // 14 is a sentinel value, greater than all other months, for the purpose of computation only; // it is impossible to actually have 14 months in a year. let leap_month = self.leap_month().unwrap_or(14); - let Some((unadjusted @ 1..13, leap)) = code.parsed() else { + let (unadjusted @ 1..13, leap) = code.to_tuple() else { return ComputedOrdinalMonth::NotFound; }; @@ -1534,12 +1533,13 @@ mod test { (13, tinystr!(4, "M12")), ]; for ordinal_code_pair in codes { - let code = MonthCode(ordinal_code_pair.1); - let ordinal = year.parse_month_code(code); + let print_code = ordinal_code_pair.1; + let valid_code = MonthCode(ordinal_code_pair.1).validated().unwrap(); + let ordinal = year.parse_month_code(valid_code); assert_eq!( ordinal, ComputedOrdinalMonth::Exact(ordinal_code_pair.0), - "Code to ordinal failed for year: {}, code: {code}", + "Code to ordinal failed for year: {}, code: {print_code}", year.related_iso ); } @@ -1562,8 +1562,8 @@ mod test { for (year, code) in invalid_codes { // construct using ::default() to force recomputation let year = LunarChinese::new_china().0.year_data(year); - let code = MonthCode(code); - let ordinal = year.parse_month_code(code); + let valid_code = MonthCode(code).validated().unwrap(); + let ordinal = year.parse_month_code(valid_code); assert!( !matches!(ordinal, ComputedOrdinalMonth::Exact(_)), "Invalid month code failed for year: {}, code: {code}", @@ -1747,7 +1747,7 @@ mod test { .0 .parse() .unwrap(); - if new_lunar_month == lunar_month.parsed().unwrap().0 { + if new_lunar_month == lunar_month.validated().unwrap().number() { lunar_month = MonthCode::new_leap(new_lunar_month).unwrap(); } else { lunar_month = MonthCode::new_normal(new_lunar_month).unwrap(); diff --git a/components/calendar/src/cal/coptic.rs b/components/calendar/src/cal/coptic.rs index d218bfe08d3..7d6c249f4f6 100644 --- a/components/calendar/src/cal/coptic.rs +++ b/components/calendar/src/cal/coptic.rs @@ -106,7 +106,7 @@ impl DateFieldsResolver for Coptic { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { Coptic::reference_year_from_month_day(month_code, day) @@ -116,10 +116,10 @@ impl DateFieldsResolver for Coptic { fn ordinal_month_from_code( &self, _year: &Self::YearInfo, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, _options: DateFromFieldsOptions, ) -> Result { - match month_code.try_parse()? { + match month_code.to_tuple() { (month_number @ 1..=13, false) => Ok(month_number), _ => Err(MonthCodeError::UnknownMonthCodeForCalendar), } @@ -128,10 +128,10 @@ impl DateFieldsResolver for Coptic { impl Coptic { pub(crate) fn reference_year_from_month_day( - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (ordinal_month, _is_leap) = month_code.try_parse()?; + let ordinal_month = month_code.number(); // December 31, 1972 occurs on 4th month, 22nd day, 1689 AM let anno_martyrum_year = if ordinal_month < 4 || (ordinal_month == 4 && day <= 22) { 1689 diff --git a/components/calendar/src/cal/ethiopian.rs b/components/calendar/src/cal/ethiopian.rs index d012c61c6ac..8923d966e45 100644 --- a/components/calendar/src/cal/ethiopian.rs +++ b/components/calendar/src/cal/ethiopian.rs @@ -102,7 +102,7 @@ impl DateFieldsResolver for Ethiopian { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { crate::cal::Coptic::reference_year_from_month_day(month_code, day) @@ -112,10 +112,10 @@ impl DateFieldsResolver for Ethiopian { fn ordinal_month_from_code( &self, _year: &Self::YearInfo, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, _options: DateFromFieldsOptions, ) -> Result { - match month_code.try_parse()? { + match month_code.to_tuple() { (month_number @ 1..=13, false) => Ok(month_number), _ => Err(MonthCodeError::UnknownMonthCodeForCalendar), } diff --git a/components/calendar/src/cal/hebrew.rs b/components/calendar/src/cal/hebrew.rs index 8c438928275..279704be91a 100644 --- a/components/calendar/src/cal/hebrew.rs +++ b/components/calendar/src/cal/hebrew.rs @@ -141,10 +141,11 @@ impl DateFieldsResolver for Hebrew { fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - month_code.try_parse()?; // return InvalidMonthCode + // Match statements are more readable with strings. + let month_code = month_code.to_month_code(); let month_code_str = month_code.0.as_str(); // December 31, 1972 occurs on 4th month, 26th day, 5733 AM let hebrew_year = match month_code_str { @@ -185,11 +186,12 @@ impl DateFieldsResolver for Hebrew { fn ordinal_month_from_code( &self, year: &Self::YearInfo, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, options: DateFromFieldsOptions, ) -> Result { + // Match statements are more readable with strings. + let month_code = month_code.to_month_code(); let is_leap_year = year.keviyah.is_leap(); - month_code.try_parse()?; // return InvalidMonthCode let month_code_str = month_code.0.as_str(); let ordinal_month = if is_leap_year { match month_code_str { @@ -250,7 +252,7 @@ impl DateFieldsResolver for Hebrew { let valid_month_code = match (ordinal_month.cmp(&6), is_leap_year) { (Ordering::Less, _) | (_, false) => ValidMonthCode::new_unchecked(ordinal_month, false), (Ordering::Equal, true) => ValidMonthCode::new_unchecked(5, true), - (Ordering::Greater, true) => ValidMonthCode::new_unchecked(ordinal_month - 1, false) + (Ordering::Greater, true) => ValidMonthCode::new_unchecked(ordinal_month - 1, false), }; let month_code = valid_month_code.to_month_code(); diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index e75e644378e..533a477e104 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -84,7 +84,7 @@ pub trait Rules: Clone + Debug + crate::cal::scaffold::UnstableSealed { /// [`MissingFieldsStrategy::Ecma`]: crate::options::MissingFieldsStrategy::Ecma fn ecma_reference_year( &self, - _month_code: types::MonthCode, + _month_code: types::ValidMonthCode, _day: u8, ) -> Result { Err(EcmaReferenceYearError::NotEnoughFields) @@ -253,10 +253,10 @@ impl Rules for UmmAlQura { fn ecma_reference_year( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (ordinal_month, false) = month_code.try_parse()? else { + let (ordinal_month, false) = month_code.to_tuple() else { return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar); }; @@ -343,10 +343,10 @@ impl Rules for TabularAlgorithm { fn ecma_reference_year( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (ordinal_month, false) = month_code.try_parse()? else { + let (ordinal_month, false) = month_code.to_tuple() else { return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar); }; @@ -610,9 +610,7 @@ fn computer_reference_years() { where C: CalendarArithmetic, { - let (ordinal_month, _is_leap) = month_code - .parsed() - .ok_or(DateError::UnknownMonthCode(month_code))?; + let ordinal_month = month_code.validated().unwrap().number(); let dec_31 = Date::from_rata_die( crate::cal::abstract_gregorian::LAST_DAY_OF_REFERENCE_YEAR, crate::Ref(cal), @@ -744,7 +742,7 @@ impl DateFieldsResolver for Hijri { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { Ok(self diff --git a/components/calendar/src/cal/indian.rs b/components/calendar/src/cal/indian.rs index 9cb55bd496f..2ff06e71436 100644 --- a/components/calendar/src/cal/indian.rs +++ b/components/calendar/src/cal/indian.rs @@ -99,10 +99,10 @@ impl DateFieldsResolver for Indian { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (ordinal_month, _is_leap) = month_code.try_parse()?; + let ordinal_month = month_code.number(); // December 31, 1972 occurs on 10th month, 10th day, 1894 Shaka // Note: 1894 Shaka is also a leap year let shaka_year = if ordinal_month < 10 || (ordinal_month == 10 && day <= 10) { diff --git a/components/calendar/src/cal/julian.rs b/components/calendar/src/cal/julian.rs index 74e32ae5909..19cb8f67cbf 100644 --- a/components/calendar/src/cal/julian.rs +++ b/components/calendar/src/cal/julian.rs @@ -125,10 +125,10 @@ impl DateFieldsResolver for Julian { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (ordinal_month, _is_leap) = month_code.try_parse()?; + let ordinal_month = month_code.number(); // December 31, 1972 occurs on 12th month, 18th day, 1972 Old Style // Note: 1972 is a leap year let julian_year = if ordinal_month < 12 || (ordinal_month == 12 && day <= 18) { diff --git a/components/calendar/src/cal/persian.rs b/components/calendar/src/cal/persian.rs index ac9b2ec2193..00848ac4613 100644 --- a/components/calendar/src/cal/persian.rs +++ b/components/calendar/src/cal/persian.rs @@ -98,10 +98,10 @@ impl DateFieldsResolver for Persian { #[inline] fn reference_year_from_month_day( &self, - month_code: types::MonthCode, + month_code: types::ValidMonthCode, day: u8, ) -> Result { - let (ordinal_month, _is_leap) = month_code.try_parse()?; + let ordinal_month = month_code.number(); // December 31, 1972 occurs on 10th month, 10th day, 1351 AP let persian_year = if ordinal_month < 10 || (ordinal_month == 10 && day <= 10) { 1351 diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index e3241ff2241..b6c36d3b224 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -8,7 +8,7 @@ use crate::error::{ }; use crate::options::{DateAddOptions, DateDifferenceOptions}; use crate::options::{DateFromFieldsOptions, MissingFieldsStrategy, Overflow}; -use crate::types::{DateDuration, DateDurationUnit, DateFields, DayOfYear, MonthCode, ValidMonthCode}; +use crate::types::{DateDuration, DateDurationUnit, DateFields, DayOfYear, ValidMonthCode}; use crate::{types, Calendar, DateError, RangeError}; use core::cmp::Ordering; use core::convert::TryInto; @@ -16,7 +16,6 @@ use core::fmt::Debug; use core::hash::{Hash, Hasher}; use core::marker::PhantomData; use core::ops::RangeInclusive; -use tinystr::tinystr; /// The range ±2²⁷. We use i32::MIN since it is -2³¹ const VALID_YEAR_RANGE: RangeInclusive = (i32::MIN / 16)..=-(i32::MIN / 16); @@ -167,7 +166,7 @@ pub(crate) trait DateFieldsResolver: Calendar { /// day for the given month. fn reference_year_from_month_day( &self, - month_code: MonthCode, + month_code: ValidMonthCode, day: u8, ) -> Result; @@ -178,10 +177,10 @@ pub(crate) trait DateFieldsResolver: Calendar { fn ordinal_month_from_code( &self, _year: &Self::YearInfo, - month_code: MonthCode, + month_code: ValidMonthCode, _options: DateFromFieldsOptions, ) -> Result { - match month_code.try_parse()? { + match month_code.to_tuple() { (month_number @ 1..=12, false) => Ok(month_number), _ => Err(MonthCodeError::UnknownMonthCodeForCalendar), } @@ -406,7 +405,7 @@ impl ArithmeticDate { // 1. Let _m0_ be MonthCodeToOrdinal(_calendar_, _y0_, ! ConstrainMonthCode(_calendar_, _y0_, _parts_.[[MonthCode]], ~constrain~)). let base_month_code = cal .month_code_from_ordinal(&self.year, self.month) - .standard_code; + .valid_standard_code; let constrain = DateFromFieldsOptions { overflow: Some(Overflow::Constrain), ..Default::default() @@ -503,26 +502,25 @@ impl ArithmeticDate { // 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_code = cal - .month_code_from_ordinal(&self.year, self.month) - .standard_code; + let base_month = cal + .month_code_from_ordinal(&self.year, self.month); let m0 = cal .ordinal_month_from_code( &y0, - base_month_code, + base_month.valid_standard_code, DateFromFieldsOptions::from_add_options(options), ) .map_err(|e| { // TODO: Use a narrower error type here. For now, convert into DateError. match e { MonthCodeError::InvalidMonthCode => { - DateError::UnknownMonthCode(base_month_code) + DateError::UnknownMonthCode(base_month.standard_code) } MonthCodeError::UnknownMonthCodeForCalendar => { - DateError::UnknownMonthCode(base_month_code) + DateError::UnknownMonthCode(base_month.standard_code) } MonthCodeError::UnknownMonthCodeForYear => { - DateError::UnknownMonthCode(base_month_code) + DateError::UnknownMonthCode(base_month.standard_code) } } })?; @@ -716,6 +714,8 @@ where return Err(DateFromFieldsError::NotEnoughFields); } + let mut valid_month_code = None; + let year = { // NOTE: The year/extendedyear range check is important to avoid arithmetic // overflow in `year_info_from_era` and `year_info_from_extended`. It @@ -735,7 +735,9 @@ where MissingFieldsStrategy::Ecma => { match (fields.month_code, fields.ordinal_month) { (Some(month_code), None) => { - cal.reference_year_from_month_day(month_code, day)? + let validated = month_code.validated()?; + valid_month_code = Some(validated); + cal.reference_year_from_month_day(validated, day)? } _ => return Err(DateFromFieldsError::NotEnoughFields), } @@ -763,7 +765,11 @@ where let month = { match fields.month_code { Some(month_code) => { - let computed_month = cal.ordinal_month_from_code(&year, month_code, options)?; + let validated = match valid_month_code { + Some(validated) => validated, + None => month_code.validated()?, + }; + let computed_month = cal.ordinal_month_from_code(&year, validated, options)?; if let Some(ordinal_month) = fields.ordinal_month { if computed_month != ordinal_month { return Err(DateFromFieldsError::InconsistentMonth); diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index 08736647b89..5c96c4aa60a 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -325,16 +325,29 @@ impl From for DateFromFieldsError { } } -/// Internal narrow error type for functions that only fail on invalid month codes -pub(crate) enum MonthCodeParseError { - InvalidMonthCode, +/// An error when validating a month code. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Display)] +#[non_exhaustive] +pub enum MonthCodeParseError { + /// The month code had invalid syntax. + #[displaydoc("The month code had invalid syntax")] + InvalidSyntax, +} + +impl From for DateFromFieldsError { + #[inline] + fn from(value: MonthCodeParseError) -> Self { + match value { + MonthCodeParseError::InvalidSyntax => DateFromFieldsError::InvalidMonthCode, + } + } } impl From for EcmaReferenceYearError { #[inline] fn from(value: MonthCodeParseError) -> Self { match value { - MonthCodeParseError::InvalidMonthCode => EcmaReferenceYearError::InvalidMonthCode, + MonthCodeParseError::InvalidSyntax => EcmaReferenceYearError::InvalidMonthCode, } } } @@ -343,7 +356,7 @@ impl From for MonthCodeError { #[inline] fn from(value: MonthCodeParseError) -> Self { match value { - MonthCodeParseError::InvalidMonthCode => MonthCodeError::InvalidMonthCode, + MonthCodeParseError::InvalidSyntax => MonthCodeError::InvalidMonthCode, } } } @@ -435,15 +448,6 @@ impl From for DateError { } } -/// An error when validating a month code. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Display)] -#[non_exhaustive] -pub enum MonthCodeParseError { - /// The month code had invalid syntax. - #[displaydoc("The month code had invalid syntax")] - InvalidSyntax, -} - pub(crate) fn range_check_with_overflow + Copy>( value: T, field: &'static str, diff --git a/components/calendar/src/types.rs b/components/calendar/src/types.rs index bd79868f5cf..2cc892051a1 100644 --- a/components/calendar/src/types.rs +++ b/components/calendar/src/types.rs @@ -212,7 +212,7 @@ impl MonthCode { } Ok(ValidMonthCode { number: (b1 - b'0') * 10 + b2 - b'0', - is_leap + is_leap, }) } @@ -294,44 +294,50 @@ pub struct ValidMonthCode { impl ValidMonthCode { /// Create a new ValidMonthCode without checking that the number is between 0 and 99 + #[inline] pub(crate) fn new_unchecked(number: u8, is_leap: bool) -> Self { debug_assert!(number <= 99); - Self { - number, is_leap - } + Self { number, is_leap } } /// Returns the month number according to the month code. - /// + /// /// This is NOT the same as the ordinal month! - /// + /// /// # Examples - /// + /// /// ``` /// use icu::calendar::Date; /// use icu::calendar::cal::Hebrew; - /// + /// /// let hebrew_date = Date::try_new_iso(2024, 7, 1).unwrap().to_calendar(Hebrew); /// let month_info = hebrew_date.month(); - /// + /// /// // Hebrew year 5784 was a leap year, so the ordinal month and month number diverge. /// assert_eq!(month_info.ordinal, 10); /// assert_eq!(month_info.valid_month_code.number(), 9); /// ``` + #[inline] pub fn number(self) -> u8 { self.number } /// Returns whether the month is a leap month. - /// + /// /// This is true for intercalary months in [`Hebrew`] and [`LunarChinese`]. - /// + /// /// [`Hebrew`]: crate::cal::Hebrew /// [`LunarChinese`]: crate::cal::LunarChinese + #[inline] pub fn is_leap(self) -> bool { self.is_leap } + #[inline] + pub(crate) fn to_tuple(self) -> (u8, bool) { + (self.number, self.is_leap) + } + pub(crate) fn to_month_code(self) -> MonthCode { let option = if self.is_leap { MonthCode::new_leap(self.number) @@ -339,7 +345,10 @@ impl ValidMonthCode { MonthCode::new_normal(self.number) }; option.unwrap_or_else(|| { - debug_assert!(false, "ValidMonthCode invariants guarantee conversion to MonthCode"); + debug_assert!( + false, + "ValidMonthCode invariants guarantee conversion to MonthCode" + ); MonthCode::SENTINEL }) } @@ -389,15 +398,12 @@ impl MonthInfo { /// if there are leap months in the year, rather it is associated with the Nth month of a "regular" /// year. There may be multiple month Ns in a year pub fn month_number(self) -> u8 { - self.standard_code - .parsed() - .map(|(i, _)| i) - .unwrap_or(self.ordinal) + self.valid_standard_code.number() } /// Get whether the month is a leap month pub fn is_leap(self) -> bool { - self.standard_code.parsed().map(|(_, l)| l).unwrap_or(false) + self.valid_standard_code.is_leap() } } diff --git a/components/datetime/src/pattern/names.rs b/components/datetime/src/pattern/names.rs index a06a3622ed3..3550558c7c5 100644 --- a/components/datetime/src/pattern/names.rs +++ b/components/datetime/src/pattern/names.rs @@ -3679,9 +3679,11 @@ impl RawDateTimeNamesBorrowed<'_> { .month_names .get_with_variables(month_name_length) .ok_or(GetNameForMonthError::NotLoaded)?; - let Some((month_number, is_leap)) = code.parsed() else { + let Ok(valid_month_code) = code.validated() else { return Err(GetNameForMonthError::InvalidMonthCode); }; + let month_number = valid_month_code.number(); + let is_leap = valid_month_code.is_leap(); let Some(month_index) = month_number.checked_sub(1) else { return Err(GetNameForMonthError::InvalidMonthCode); }; From 4872cd71339426d5d8442c4c6db9cfa8a796694d Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Wed, 15 Oct 2025 18:34:25 -0700 Subject: [PATCH 3/6] Make things more private --- components/calendar/src/error.rs | 9 +++------ components/calendar/src/types.rs | 15 ++++++++++----- components/datetime/src/pattern/names.rs | 4 +--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index 5c96c4aa60a..d12dccb72c2 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -325,12 +325,9 @@ impl From for DateFromFieldsError { } } -/// An error when validating a month code. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Display)] -#[non_exhaustive] -pub enum MonthCodeParseError { - /// The month code had invalid syntax. - #[displaydoc("The month code had invalid syntax")] +/// Internal narrow error type for functions that only fail on parsing month codes +#[derive(Debug)] +pub(crate) enum MonthCodeParseError { InvalidSyntax, } diff --git a/components/calendar/src/types.rs b/components/calendar/src/types.rs index 2cc892051a1..fe49ebd07b1 100644 --- a/components/calendar/src/types.rs +++ b/components/calendar/src/types.rs @@ -188,7 +188,6 @@ impl MonthCode { } } /// Get the month number and whether or not it is leap from the month code - #[deprecated(since = "2.1", note = "use validated()")] pub fn parsed(self) -> Option<(u8, bool)> { let valid_month_code = self.validated().ok()?; Some((valid_month_code.number(), valid_month_code.is_leap())) @@ -196,7 +195,7 @@ impl MonthCode { /// Validates the syntax and returns a [`ValidMonthCode`], from which the /// month number and leap month status can be read. - pub fn validated(self) -> Result { + pub(crate) fn validated(self) -> Result { // Match statements on tinystrs are annoying so instead // we calculate it from the bytes directly @@ -285,7 +284,13 @@ impl fmt::Display for MonthCode { } /// A [`MonthCode`] that has been parsed into its internal representation. +/// +///
+/// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways, +/// including in SemVer minor releases. +///
#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(not(feature = "unstable"), doc(hidden))] // public because of Rules traits pub struct ValidMonthCode { /// Month number between 0 and 99 number: u8, @@ -374,11 +379,11 @@ pub struct MonthInfo { /// /// [`Date::try_new_from_codes`]: crate::Date::try_new_from_codes /// [`Date::try_from_fields`]: crate::Date::try_from_fields - pub valid_standard_code: ValidMonthCode, - - /// Same as [`Self::valid_standard_code`] but without syntax invariants. pub standard_code: MonthCode, + /// Same as [`Self::standard_code`] but with invariants validated. + pub(crate) valid_standard_code: ValidMonthCode, + /// A month code, useable for formatting. /// /// Does NOT necessarily round-trip through `Date` constructors like [`Date::try_new_from_codes`] and [`Date::try_from_fields`]. diff --git a/components/datetime/src/pattern/names.rs b/components/datetime/src/pattern/names.rs index 3550558c7c5..a06a3622ed3 100644 --- a/components/datetime/src/pattern/names.rs +++ b/components/datetime/src/pattern/names.rs @@ -3679,11 +3679,9 @@ impl RawDateTimeNamesBorrowed<'_> { .month_names .get_with_variables(month_name_length) .ok_or(GetNameForMonthError::NotLoaded)?; - let Ok(valid_month_code) = code.validated() else { + let Some((month_number, is_leap)) = code.parsed() else { return Err(GetNameForMonthError::InvalidMonthCode); }; - let month_number = valid_month_code.number(); - let is_leap = valid_month_code.is_leap(); let Some(month_index) = month_number.checked_sub(1) else { return Err(GetNameForMonthError::InvalidMonthCode); }; From 719b2ad84cc87dd6bd2cd2d34150befd7fd8d701 Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Wed, 15 Oct 2025 18:52:39 -0700 Subject: [PATCH 4/6] Make ValidMonthCode fully pub(crate). EcmaReferenceYearError::Unimplemented --- components/calendar/src/cal/chinese.rs | 33 +++++++++---------- components/calendar/src/cal/hijri.rs | 24 +++++++------- .../calendar/src/calendar_arithmetic.rs | 3 +- components/calendar/src/error.rs | 15 ++------- components/calendar/src/types.rs | 10 ++---- 5 files changed, 34 insertions(+), 51 deletions(-) diff --git a/components/calendar/src/cal/chinese.rs b/components/calendar/src/cal/chinese.rs index a416c93311f..4e3e0ed1469 100644 --- a/components/calendar/src/cal/chinese.rs +++ b/components/calendar/src/cal/chinese.rs @@ -121,10 +121,11 @@ pub trait Rules: Clone + core::fmt::Debug + crate::cal::scaffold::UnstableSealed /// [`MissingFieldsStrategy::Ecma`]: crate::options::MissingFieldsStrategy::Ecma fn ecma_reference_year( &self, - _month_code: types::ValidMonthCode, + // TODO: Consider accepting ValidMonthCode + _month_code: (u8, bool), _day: u8, ) -> Result { - Err(EcmaReferenceYearError::NotEnoughFields) + Err(EcmaReferenceYearError::Unimplemented) } /// The debug name for the calendar defined by these [`Rules`]. @@ -204,12 +205,12 @@ impl Rules for China { fn ecma_reference_year( &self, - month_code: types::ValidMonthCode, + month_code: (u8, bool), day: u8, ) -> Result { - let (number, is_leap) = month_code.to_tuple(); + let (number, is_leap) = month_code; // Computed by `generate_reference_years` - Ok(match (number, is_leap, day > 29) { + let extended_year = match (number, is_leap, day > 29) { (1, false, false) => 1972, (1, false, true) => 1970, (1, true, false) => 1898, @@ -262,7 +263,8 @@ impl Rules for China { (12, true, false) => 1878, (12, true, true) => 1783, _ => return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar), - }) + }; + Ok(extended_year) } fn calendar_algorithm(&self) -> Option { @@ -385,12 +387,12 @@ impl Rules for Korea { fn ecma_reference_year( &self, - month_code: types::ValidMonthCode, + month_code: (u8, bool), day: u8, ) -> Result { - let (number, is_leap) = month_code.to_tuple(); + let (number, is_leap) = month_code; // Computed by `generate_reference_years` - Ok(match (number, is_leap, day > 29) { + let extended_year = match (number, is_leap, day > 29) { (1, false, false) => 1972, (1, false, true) => 1970, (1, true, false) => 1898, @@ -443,7 +445,8 @@ impl Rules for Korea { (12, true, false) => 1878, (12, true, true) => 1783, _ => return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar), - }) + }; + Ok(extended_year) } fn calendar_algorithm(&self) -> Option { @@ -579,9 +582,9 @@ impl DateFieldsResolver for LunarChinese { month_code: types::ValidMonthCode, day: u8, ) -> Result { - Ok(self - .0 - .year_data(self.0.ecma_reference_year(month_code, day)?)) + self.0 + .ecma_reference_year(month_code.to_tuple(), day) + .map(|y| self.0.year_data(y)) } fn ordinal_month_from_code( @@ -1550,10 +1553,6 @@ mod test { let non_leap_year = 4659; let leap_year = 4660; let invalid_codes = [ - (non_leap_year, tinystr!(4, "M2")), - (leap_year, tinystr!(4, "M0")), - (non_leap_year, tinystr!(4, "J01")), - (leap_year, tinystr!(4, "3M")), (non_leap_year, tinystr!(4, "M04L")), (leap_year, tinystr!(4, "M04L")), (non_leap_year, tinystr!(4, "M13")), diff --git a/components/calendar/src/cal/hijri.rs b/components/calendar/src/cal/hijri.rs index 533a477e104..4fd76348fcc 100644 --- a/components/calendar/src/cal/hijri.rs +++ b/components/calendar/src/cal/hijri.rs @@ -84,10 +84,11 @@ pub trait Rules: Clone + Debug + crate::cal::scaffold::UnstableSealed { /// [`MissingFieldsStrategy::Ecma`]: crate::options::MissingFieldsStrategy::Ecma fn ecma_reference_year( &self, - _month_code: types::ValidMonthCode, + // TODO: Consider accepting ValidMonthCode + _month_code: (u8, bool), _day: u8, ) -> Result { - Err(EcmaReferenceYearError::NotEnoughFields) + Err(EcmaReferenceYearError::Unimplemented) } /// The BCP-47 [`CalendarAlgorithm`] for the Hijri calendar using these rules, if defined. @@ -253,14 +254,14 @@ impl Rules for UmmAlQura { fn ecma_reference_year( &self, - month_code: types::ValidMonthCode, + month_code: (u8, bool), day: u8, ) -> Result { - let (ordinal_month, false) = month_code.to_tuple() else { + let (ordinal_month, false) = month_code else { return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar); }; - Ok(match (ordinal_month, day) { + let extended_year = match (ordinal_month, day) { (1, _) => 1392, (2, 30..) => 1390, (2, _) => 1392, @@ -281,7 +282,8 @@ impl Rules for UmmAlQura { (12, 30..) => 1390, (12, _) => 1391, _ => return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar), - }) + }; + Ok(extended_year) } fn debug_name(&self) -> &'static str { @@ -343,10 +345,10 @@ impl Rules for TabularAlgorithm { fn ecma_reference_year( &self, - month_code: types::ValidMonthCode, + month_code: (u8, bool), day: u8, ) -> Result { - let (ordinal_month, false) = month_code.to_tuple() else { + let (ordinal_month, false) = month_code else { return Err(EcmaReferenceYearError::UnknownMonthCodeForCalendar); }; @@ -745,9 +747,9 @@ impl DateFieldsResolver for Hijri { month_code: types::ValidMonthCode, day: u8, ) -> Result { - Ok(self - .0 - .year_data(self.0.ecma_reference_year(month_code, day)?)) + self.0 + .ecma_reference_year(month_code.to_tuple(), day) + .map(|y| self.0.year_data(y)) } } diff --git a/components/calendar/src/calendar_arithmetic.rs b/components/calendar/src/calendar_arithmetic.rs index b6c36d3b224..29fa37d59c0 100644 --- a/components/calendar/src/calendar_arithmetic.rs +++ b/components/calendar/src/calendar_arithmetic.rs @@ -502,8 +502,7 @@ impl ArithmeticDate { // 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_code_from_ordinal(&self.year, self.month); + let base_month = cal.month_code_from_ordinal(&self.year, self.month); let m0 = cal .ordinal_month_from_code( &y0, diff --git a/components/calendar/src/error.rs b/components/calendar/src/error.rs index d12dccb72c2..ba13ba20601 100644 --- a/components/calendar/src/error.rs +++ b/components/calendar/src/error.rs @@ -340,15 +340,6 @@ impl From for DateFromFieldsError { } } -impl From for EcmaReferenceYearError { - #[inline] - fn from(value: MonthCodeParseError) -> Self { - match value { - MonthCodeParseError::InvalidSyntax => EcmaReferenceYearError::InvalidMonthCode, - } - } -} - impl From for MonthCodeError { #[inline] fn from(value: MonthCodeParseError) -> Self { @@ -386,9 +377,8 @@ mod inner { #[allow(missing_docs)] // TODO: fix when graduating #[non_exhaustive] pub enum EcmaReferenceYearError { - InvalidMonthCode, + Unimplemented, UnknownMonthCodeForCalendar, - NotEnoughFields, } } @@ -401,11 +391,10 @@ impl From for DateFromFieldsError { #[inline] fn from(value: EcmaReferenceYearError) -> Self { match value { - EcmaReferenceYearError::InvalidMonthCode => DateFromFieldsError::InvalidMonthCode, + EcmaReferenceYearError::Unimplemented => DateFromFieldsError::NotEnoughFields, EcmaReferenceYearError::UnknownMonthCodeForCalendar => { DateFromFieldsError::UnknownMonthCodeForCalendar } - EcmaReferenceYearError::NotEnoughFields => DateFromFieldsError::NotEnoughFields, } } } diff --git a/components/calendar/src/types.rs b/components/calendar/src/types.rs index fe49ebd07b1..d8209e07c03 100644 --- a/components/calendar/src/types.rs +++ b/components/calendar/src/types.rs @@ -284,14 +284,8 @@ impl fmt::Display for MonthCode { } /// A [`MonthCode`] that has been parsed into its internal representation. -/// -///
-/// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways, -/// including in SemVer minor releases. -///
#[derive(Copy, Clone, Debug, PartialEq)] -#[cfg_attr(not(feature = "unstable"), doc(hidden))] // public because of Rules traits -pub struct ValidMonthCode { +pub(crate) struct ValidMonthCode { /// Month number between 0 and 99 number: u8, is_leap: bool, @@ -311,7 +305,7 @@ impl ValidMonthCode { /// /// # Examples /// - /// ``` + /// ```ignore /// use icu::calendar::Date; /// use icu::calendar::cal::Hebrew; /// From d79ec77d0be5da34215de136606853668b9bb5c4 Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Wed, 15 Oct 2025 19:04:09 -0700 Subject: [PATCH 5/6] whoops, fix Hebrew formatting code --- components/calendar/src/cal/hebrew.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/components/calendar/src/cal/hebrew.rs b/components/calendar/src/cal/hebrew.rs index 279704be91a..6fafedab56e 100644 --- a/components/calendar/src/cal/hebrew.rs +++ b/components/calendar/src/cal/hebrew.rs @@ -254,13 +254,18 @@ impl DateFieldsResolver for Hebrew { (Ordering::Equal, true) => ValidMonthCode::new_unchecked(5, true), (Ordering::Greater, true) => ValidMonthCode::new_unchecked(ordinal_month - 1, false), }; - let month_code = valid_month_code.to_month_code(); + let standard_code = valid_month_code.to_month_code(); + let formatting_code = if is_leap_year && ordinal_month == 7 { + ValidMonthCode::new_unchecked(6, true).to_month_code() // M06L + } else { + standard_code + }; types::MonthInfo { ordinal: ordinal_month, valid_standard_code: valid_month_code, - standard_code: month_code, - formatting_code: month_code, + standard_code, + formatting_code, } } } From 3fd52bccef9087edbca07fbb6fde19dff9595bce Mon Sep 17 00:00:00 2001 From: "Shane F. Carr" Date: Wed, 15 Oct 2025 19:37:06 -0700 Subject: [PATCH 6/6] Add test for month code error types --- components/calendar/tests/month_code.rs | 166 ++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 components/calendar/tests/month_code.rs diff --git a/components/calendar/tests/month_code.rs b/components/calendar/tests/month_code.rs new file mode 100644 index 00000000000..4cece87d52e --- /dev/null +++ b/components/calendar/tests/month_code.rs @@ -0,0 +1,166 @@ +// This file is part of ICU4X. For terms of use, please see the file +// called LICENSE at the top level of the ICU4X source tree +// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). + +use icu_calendar::{ + error::DateFromFieldsError, + options::{DateFromFieldsOptions, MissingFieldsStrategy}, + types::{DateFields, MonthCode}, + AnyCalendar, AnyCalendarKind, Date, Ref, +}; + +static INVALID_SYNTAX: &[&str] = &[ + "M", "M0", "M1", "01L", "L01", "M001", "M110", "MxxL", "m01", "M02l", +]; + +static NOT_IN_ANY_CALENDAR: &[&str] = &["M00", "M14", "M99", "M13L"]; + +static CHINESE_ONLY: &[&str] = &[ + "M01L", "M02L", "M03L", "M04L", "M06L", "M07L", "M08L", "M09L", "M10L", "M11L", "M12L", +]; + +static CHINESE_HEBREW: &[&str] = &["M05L"]; + +static COPTIC_ONLY: &[&str] = &["M13"]; + +static UNIVERSAL_MONTH_CODES: &[&str] = &[ + "M01", "M02", "M03", "M04", "M05", "M06", "M07", "M08", "M09", "M10", "M11", "M12", +]; + +#[test] +fn test_month_code_errors() { + for kind in [ + AnyCalendarKind::Buddhist, + AnyCalendarKind::Chinese, + AnyCalendarKind::Coptic, + AnyCalendarKind::Dangi, + AnyCalendarKind::Ethiopian, + AnyCalendarKind::EthiopianAmeteAlem, + AnyCalendarKind::Gregorian, + AnyCalendarKind::Hebrew, + AnyCalendarKind::HijriUmmAlQura, + ] { + let cal = AnyCalendar::new(kind); + + let mut valid_month_codes = UNIVERSAL_MONTH_CODES.to_vec(); + let mut invalid_month_codes = NOT_IN_ANY_CALENDAR.to_vec(); + + if matches!(kind, AnyCalendarKind::Chinese | AnyCalendarKind::Dangi) { + &mut valid_month_codes + } else { + &mut invalid_month_codes + } + .extend_from_slice(CHINESE_ONLY); + + if matches!( + kind, + AnyCalendarKind::Chinese | AnyCalendarKind::Dangi | AnyCalendarKind::Hebrew + ) { + &mut valid_month_codes + } else { + &mut invalid_month_codes + } + .extend_from_slice(CHINESE_HEBREW); + + if matches!( + kind, + AnyCalendarKind::Coptic + | AnyCalendarKind::Ethiopian + | AnyCalendarKind::EthiopianAmeteAlem + ) { + &mut valid_month_codes + } else { + &mut invalid_month_codes + } + .extend_from_slice(COPTIC_ONLY); + + // Test with full dates + for extended_year in -100..=100 { + let options = DateFromFieldsOptions::default(); + let mut fields = DateFields::default(); + fields.extended_year = Some(extended_year); + fields.day = Some(1); + for month_code in valid_month_codes.iter() { + fields.month_code = Some(MonthCode(month_code.parse().unwrap())); + match Date::try_from_fields(fields, options, Ref(&cal)) { + Ok(_) => (), + Err(DateFromFieldsError::UnknownMonthCodeForYear) => (), + Err(e) => { + panic!("Should have succeeded, but failed: {kind:?} {extended_year} {month_code} {e:?}"); + } + } + } + for month_code in invalid_month_codes.iter() { + fields.month_code = Some(MonthCode(month_code.parse().unwrap())); + match Date::try_from_fields(fields, options, Ref(&cal)) { + Err(DateFromFieldsError::UnknownMonthCodeForCalendar) => (), + Ok(_) => { + panic!("Should have failed, but succeeded: {kind:?} {extended_year} {month_code}"); + } + Err(e) => { + panic!( + "Failed with wrong error: {kind:?} {extended_year} {month_code} {e:?}" + ); + } + } + } + for month_code in INVALID_SYNTAX.iter() { + fields.month_code = Some(MonthCode(month_code.parse().unwrap())); + match Date::try_from_fields(fields, options, Ref(&cal)) { + Err(DateFromFieldsError::InvalidMonthCode) => (), + Ok(_) => { + panic!("Should have failed, but succeeded: {kind:?} {extended_year} {month_code}"); + } + Err(e) => { + panic!( + "Failed with wrong error: {kind:?} {extended_year} {month_code} {e:?}" + ); + } + } + } + } + + // Test with reference year + let mut fields = DateFields::default(); + fields.day = Some(1); + let mut options = DateFromFieldsOptions::default(); + options.missing_fields_strategy = Some(MissingFieldsStrategy::Ecma); + for month_code in valid_month_codes.iter() { + fields.month_code = Some(MonthCode(month_code.parse().unwrap())); + match Date::try_from_fields(fields, options, Ref(&cal)) { + Ok(_) => (), + Err(e) => { + panic!("Should have succeeded, but failed: {kind:?} {month_code} {e:?} (reference year)"); + } + } + } + for month_code in invalid_month_codes.iter() { + fields.month_code = Some(MonthCode(month_code.parse().unwrap())); + match Date::try_from_fields(fields, options, Ref(&cal)) { + Err(DateFromFieldsError::UnknownMonthCodeForCalendar) => (), + Ok(_) => { + panic!( + "Should have failed, but succeeded: {kind:?} {month_code} (reference year)" + ); + } + Err(e) => { + panic!("Failed with wrong error: {kind:?} {month_code} {e:?} (reference year)"); + } + } + } + for month_code in INVALID_SYNTAX.iter() { + fields.month_code = Some(MonthCode(month_code.parse().unwrap())); + match Date::try_from_fields(fields, options, Ref(&cal)) { + Err(DateFromFieldsError::InvalidMonthCode) => (), + Ok(_) => { + panic!( + "Should have failed, but succeeded: {kind:?} {month_code} (reference year)" + ); + } + Err(e) => { + panic!("Failed with wrong error: {kind:?} {month_code} {e:?} (reference year)"); + } + } + } + } +}