Skip to main content

icu_calendar/
ixdtf.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use core::str::FromStr;
6
7use crate::cal::abstract_gregorian::AbstractGregorianYear;
8use crate::cal::iso::IsoDateInner;
9use crate::calendar_arithmetic::{ArithmeticDate, VALID_RD_RANGE};
10use crate::types::RataDie;
11use crate::{AsCalendar, Calendar, Date, Iso, RangeError};
12use calendrical_calculations::gregorian::fixed_from_gregorian;
13use icu_locale_core::preferences::extensions::unicode::keywords::CalendarAlgorithm;
14use ixdtf::ParseError as Rfc9557Error;
15use ixdtf::encoding::Utf8;
16use ixdtf::parsers::IxdtfParser;
17use ixdtf::records::IxdtfParseRecord;
18
19/// An error returned from parsing an RFC 9557 string to an `icu::calendar` type.
20#[derive(Debug, displaydoc::Display)]
21#[non_exhaustive]
22pub enum ParseError {
23    /// Syntax error.
24    #[displaydoc("Syntax error in the RFC 9557 string: {0}")]
25    Syntax(Rfc9557Error),
26    /// Value is out of range.
27    #[displaydoc("Value out of range: {0}")]
28    Range(RangeError),
29    /// The RFC 9557 string is missing fields required for parsing into the chosen type.
30    MissingFields,
31    /// The RFC 9557 string specifies an unknown calendar.
32    UnknownCalendar,
33    /// Expected a different calendar.
34    #[displaydoc("Expected calendar {0:?} but found calendar {1:?}")]
35    MismatchedCalendar(CalendarAlgorithm, CalendarAlgorithm),
36}
37
38impl From<RangeError> for ParseError {
39    fn from(value: RangeError) -> Self {
40        Self::Range(value)
41    }
42}
43
44impl From<Rfc9557Error> for ParseError {
45    fn from(value: Rfc9557Error) -> Self {
46        Self::Syntax(value)
47    }
48}
49
50impl FromStr for Date<Iso> {
51    type Err = ParseError;
52    fn from_str(rfc_9557_str: &str) -> Result<Self, Self::Err> {
53        Self::try_from_str(rfc_9557_str, Iso)
54    }
55}
56
57impl<A: AsCalendar> Date<A> {
58    /// Creates a [`Date`] in the given calendar from an RFC 9557 string.
59    ///
60    /// Returns an error if the string has a calendar annotation that does not
61    /// match the calendar argument.
62    ///
63    /// ✨ *Enabled with the `ixdtf` Cargo feature.*
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use icu::calendar::{Date, Gregorian};
69    ///
70    /// let date = Date::try_from_str("2024-07-17", Gregorian).unwrap();
71    /// let date =
72    ///     Date::try_from_str("2024-07-17[u-ca=gregory]", Gregorian).unwrap();
73    /// let _ =
74    ///     Date::try_from_str("2024-07-17[u-ca=hebrew]", Gregorian).unwrap_err();
75    ///
76    /// assert_eq!(date.era_year().year, 2024);
77    /// assert_eq!(date.month().number(), 7);
78    /// assert_eq!(date.day_of_month().0, 17);
79    /// ```
80    pub fn try_from_str(rfc_9557_str: &str, calendar: A) -> Result<Self, ParseError> {
81        Self::try_from_utf8(rfc_9557_str.as_bytes(), calendar)
82    }
83
84    /// Creates a [`Date`] in the given calendar from an RFC 9557 string.
85    ///
86    /// Returns an error if the string has a calendar annotation that does not
87    /// match the calendar argument.
88    ///
89    /// See [`Self::try_from_str()`].
90    ///
91    /// ✨ *Enabled with the `ixdtf` Cargo feature.*
92    pub fn try_from_utf8(rfc_9557_str: &[u8], calendar: A) -> Result<Self, ParseError> {
93        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
94        Self::try_from_ixdtf_record(&ixdtf_record, calendar)
95    }
96
97    #[doc(hidden)]
98    pub fn try_from_ixdtf_record(
99        ixdtf_record: &IxdtfParseRecord<'_, Utf8>,
100        calendar: A,
101    ) -> Result<Self, ParseError> {
102        let inner = Self::try_inner_from_ixdtf_record(
103            ixdtf_record,
104            calendar.as_calendar().calendar_algorithm(),
105        )?;
106
107        // this is free for AbstractGregorian calendars
108        Ok(Date::from_raw(inner, Iso).to_calendar(calendar))
109    }
110
111    // like `try_from_ixdtf_record`, but also returns the RD, which is useful
112    // for creating the `ZoneNameTimestamp`
113    #[doc(hidden)]
114    pub fn try_from_ixdtf_record_with_rd(
115        ixdtf_record: &IxdtfParseRecord<'_, Utf8>,
116        calendar: A,
117    ) -> Result<(Self, RataDie), ParseError> {
118        let inner = Self::try_inner_from_ixdtf_record(
119            ixdtf_record,
120            calendar.as_calendar().calendar_algorithm(),
121        )?;
122
123        let rd = Iso.to_rata_die(&inner);
124
125        let c = calendar.as_calendar();
126        let inner = if c.has_cheap_iso_conversion() {
127            // no-op
128            c.from_iso(inner)
129        } else {
130            // `from_rata_die` precondition is satified by `to_rata_die`
131            c.from_rata_die(rd)
132        };
133
134        Ok((Date::from_raw(inner, calendar), rd))
135    }
136
137    // Parses into `IsoDateInner` but validates the calendar annotation.
138    // Does not perform any arithmetic.
139    fn try_inner_from_ixdtf_record(
140        ixdtf_record: &IxdtfParseRecord<'_, Utf8>,
141        calendar: Option<CalendarAlgorithm>,
142    ) -> Result<IsoDateInner, ParseError> {
143        let date_record = ixdtf_record.date.ok_or(ParseError::MissingFields)?;
144
145        if let Some(ixdtf_calendar) = ixdtf_record.calendar
146            && let Some(expected_calendar) = calendar
147            && ixdtf_calendar != expected_calendar.as_str().as_bytes()
148        {
149            return Err(ParseError::MismatchedCalendar(
150                expected_calendar,
151                icu_locale_core::extensions::unicode::Value::try_from_utf8(ixdtf_calendar)
152                    .ok()
153                    .and_then(|v| CalendarAlgorithm::try_from(&v).ok())
154                    .ok_or(ParseError::UnknownCalendar)?,
155            ));
156        }
157
158        // `date_record` is in `VALID_RD_RANGE` by `ixdtf` invariants
159        const _: () = assert!(
160            VALID_RD_RANGE.start().to_i64_date()
161                <= fixed_from_gregorian(-999_999, 1, 1).to_i64_date()
162                && fixed_from_gregorian(999_999, 12, 31).to_i64_date()
163                    <= VALID_RD_RANGE.end().to_i64_date()
164        );
165
166        Ok(IsoDateInner(ArithmeticDate::new_unchecked(
167            AbstractGregorianYear::from_iso_year(date_record.year),
168            date_record.month,
169            date_record.day,
170        )))
171    }
172}
173
174#[test]
175fn invalid_calendar() {
176    Date::try_from_str("2025-01-01T00:00:00[u-ca=foo]", crate::Gregorian).unwrap_err();
177}