diff --git a/components/timezone/src/ixdtf.rs b/components/timezone/src/ixdtf.rs index 89a28bb5a1a..b1abd2dbf05 100644 --- a/components/timezone/src/ixdtf.rs +++ b/components/timezone/src/ixdtf.rs @@ -42,6 +42,8 @@ pub enum ParseError { InconsistentTimeZoneOffsets, /// There was an invalid Offset. InvalidOffsetError, + /// Parsed fraction had excessive precision beyond nanosecond. + ExcessivePrecision, /// The set of time zone fields was not expected for the given type. /// For example, if a named time zone was present with offset-only parsing, /// or an offset was present with named-time-zone-only parsing. @@ -326,12 +328,7 @@ impl<'a> Intermediate<'a> { }; let time_zone_id = mapper.iana_bytes_to_bcp47(iana_identifier); let date = Date::::try_new_iso(self.date.year, self.date.month, self.date.day)?; - let time = Time::try_new( - self.time.hour, - self.time.minute, - self.time.second, - self.time.nanosecond, - )?; + let time = Time::try_from_time_record(&self.time)?; let offset = match time_zone_id.as_str() { "utc" | "gmt" => Some(UtcOffset::zero()), _ => None, @@ -367,12 +364,7 @@ impl<'a> Intermediate<'a> { }, }; let date = Date::::try_new_iso(self.date.year, self.date.month, self.date.day)?; - let time = Time::try_new( - self.time.hour, - self.time.minute, - self.time.second, - self.time.nanosecond, - )?; + let time = Time::try_from_time_record(&self.time)?; Ok(time_zone_id.with_offset(offset).at_time((date, time))) } @@ -389,12 +381,7 @@ impl<'a> Intermediate<'a> { }; let time_zone_id = mapper.iana_bytes_to_bcp47(iana_identifier); let date = Date::try_new_iso(self.date.year, self.date.month, self.date.day)?; - let time = Time::try_new( - self.time.hour, - self.time.minute, - self.time.second, - self.time.nanosecond, - )?; + let time = Time::try_from_time_record(&self.time)?; let offset = UtcOffset::try_from_utc_offset_record(offset)?; let zone_variant = match zone_offset_calculator .compute_offsets_from_time_zone(time_zone_id, (date, time)) @@ -792,13 +779,19 @@ impl Time { fn try_from_ixdtf_record(ixdtf_record: &IxdtfParseRecord) -> Result { let time_record = ixdtf_record.time.ok_or(ParseError::MissingFields)?; - let time = Self::try_new( + Self::try_from_time_record(&time_record) + } + + fn try_from_time_record(time_record: &TimeRecord) -> Result { + let Some(nanosecond) = time_record.fraction.to_nanoseconds() else { + return Err(ParseError::ExcessivePrecision); + }; + Ok(Self::try_new( time_record.hour, time_record.minute, time_record.second, - time_record.nanosecond, - )?; - Ok(time) + nanosecond, + )?) } } diff --git a/utils/ixdtf/README.md b/utils/ixdtf/README.md index a0c752fcf3d..00985758ae2 100644 --- a/utils/ixdtf/README.md +++ b/utils/ixdtf/README.md @@ -20,7 +20,7 @@ RFC 9557 also updates the interpretation of `Z` from RFC 3339. ```rust use ixdtf::parsers::{ - records::{Sign, TimeZoneRecord}, + records::{Sign, TimeZoneRecord, Fraction}, IxdtfParser, }; @@ -42,7 +42,7 @@ assert_eq!(offset.sign, Sign::Negative); assert_eq!(offset.hour, 5); assert_eq!(offset.minute, 0); assert_eq!(offset.second, 0); -assert_eq!(offset.nanosecond, 0); +assert_eq!(offset.fraction, Fraction { digits: 0, value: 0}); assert!(!tz_annotation.critical); assert_eq!( tz_annotation.tz, @@ -74,7 +74,7 @@ RFC 9557 updates the interpretation of `Z` to align with `-00:00`. ```rust use ixdtf::parsers::{ - records::{Sign, TimeZoneRecord}, + records::{Sign, TimeZoneRecord, Fraction}, IxdtfParser, }; @@ -96,7 +96,7 @@ assert_eq!(offset.sign, Sign::Negative); assert_eq!(offset.hour, 0); assert_eq!(offset.minute, 0); assert_eq!(offset.second, 0); -assert_eq!(offset.nanosecond, 0); +assert_eq!(offset.fraction, Fraction { digits: 0, value: 0}); assert!(!tz_annotation.critical); assert_eq!( tz_annotation.tz, @@ -135,7 +135,7 @@ zone annotation if it is provided. ```rust use ixdtf::parsers::{ - records::{Sign, TimeZoneRecord}, + records::{Sign, TimeZoneRecord, Fraction}, IxdtfParser, }; @@ -152,7 +152,7 @@ assert_eq!(offset.sign, Sign::Negative); assert_eq!(offset.hour, 0); assert_eq!(offset.minute, 0); assert_eq!(offset.second, 0); -assert_eq!(offset.nanosecond, 0); +assert_eq!(offset.fraction, Fraction { digits: 0, value: 0}); assert!(tz_annotation.critical); assert_eq!( tz_annotation.tz, diff --git a/utils/ixdtf/src/error.rs b/utils/ixdtf/src/error.rs index 0efafdfbe74..6e459eda3fb 100644 --- a/utils/ixdtf/src/error.rs +++ b/utils/ixdtf/src/error.rs @@ -102,6 +102,8 @@ pub enum ParseError { DateDurationPartOrder, #[displaydoc("Invalid time duration part order.")] TimeDurationPartOrder, + #[displaydoc("Duration part fraction exceeded a representable range.")] + DurationFractionExceededRange, #[displaydoc("Invalid time duration designator.")] TimeDurationDesignator, } diff --git a/utils/ixdtf/src/lib.rs b/utils/ixdtf/src/lib.rs index 8e6b587244b..b77baffa5e8 100644 --- a/utils/ixdtf/src/lib.rs +++ b/utils/ixdtf/src/lib.rs @@ -20,7 +20,7 @@ //! //! ``` //! use ixdtf::parsers::{ -//! records::{Sign, TimeZoneRecord}, +//! records::{Sign, TimeZoneRecord, Fraction}, //! IxdtfParser, //! }; //! @@ -42,7 +42,7 @@ //! assert_eq!(offset.hour, 5); //! assert_eq!(offset.minute, 0); //! assert_eq!(offset.second, 0); -//! assert_eq!(offset.nanosecond, 0); +//! assert_eq!(offset.fraction, Fraction { digits: 0, value: 0}); //! assert!(!tz_annotation.critical); //! assert_eq!( //! tz_annotation.tz, @@ -74,7 +74,7 @@ //! //! ```rust //! use ixdtf::parsers::{ -//! records::{Sign, TimeZoneRecord}, +//! records::{Sign, TimeZoneRecord, Fraction}, //! IxdtfParser, //! }; //! @@ -96,7 +96,7 @@ //! assert_eq!(offset.hour, 0); //! assert_eq!(offset.minute, 0); //! assert_eq!(offset.second, 0); -//! assert_eq!(offset.nanosecond, 0); +//! assert_eq!(offset.fraction, Fraction { digits: 0, value: 0}); //! assert!(!tz_annotation.critical); //! assert_eq!( //! tz_annotation.tz, @@ -135,7 +135,7 @@ //! //! ```rust //! use ixdtf::parsers::{ -//! records::{Sign, TimeZoneRecord}, +//! records::{Sign, TimeZoneRecord, Fraction}, //! IxdtfParser, //! }; //! @@ -152,7 +152,7 @@ //! assert_eq!(offset.hour, 0); //! assert_eq!(offset.minute, 0); //! assert_eq!(offset.second, 0); -//! assert_eq!(offset.nanosecond, 0); +//! assert_eq!(offset.fraction, Fraction { digits: 0, value: 0}); //! assert!(tz_annotation.critical); //! assert_eq!( //! tz_annotation.tz, diff --git a/utils/ixdtf/src/parsers/duration.rs b/utils/ixdtf/src/parsers/duration.rs index e87f2b501f6..031d0a2cfc9 100644 --- a/utils/ixdtf/src/parsers/duration.rs +++ b/utils/ixdtf/src/parsers/duration.rs @@ -12,7 +12,7 @@ use crate::{ is_month_designator, is_second_designator, is_sign, is_time_designator, is_week_designator, is_year_designator, }, - records::{DateDurationRecord, DurationParseRecord, TimeDurationRecord}, + records::{DateDurationRecord, DurationParseRecord, Fraction, TimeDurationRecord}, time::parse_fraction, Cursor, }, @@ -134,7 +134,7 @@ pub(crate) fn parse_time_duration(cursor: &mut Cursor) -> ParserResult) = (0, 0, 0, None); + let mut time: (u64, u64, u64, Option) = (0, 0, 0, None); let mut previous_unit = TimeUnit::None; while cursor.check_or(false, |c| c.is_ascii_digit()) { let mut value: u64 = 0; @@ -194,20 +194,39 @@ pub(crate) fn parse_time_duration(cursor: &mut Cursor) -> ParserResult see test maximum_duration_fraction TimeUnit::Hour => Ok(Some(TimeDurationRecord::Hours { hours: time.0, - fraction: time.3.map(|f| 3600 * u64::from(f)).unwrap_or(0), + fraction: time + .3 + .map(|f| adjust_fraction_for_unit(f, 3600)) + .transpose()? + .unwrap_or_default(), })), // Safety: Max fraction * 60 is within u64 -> see test maximum_duration_fraction TimeUnit::Minute => Ok(Some(TimeDurationRecord::Minutes { hours: time.0, minutes: time.1, - fraction: time.3.map(|f| 60 * u64::from(f)).unwrap_or(0), + fraction: time + .3 + .map(|f| adjust_fraction_for_unit(f, 60)) + .transpose()? + .unwrap_or_default(), })), TimeUnit::Second => Ok(Some(TimeDurationRecord::Seconds { hours: time.0, minutes: time.1, seconds: time.2, - fraction: time.3.unwrap_or(0), + fraction: time.3.unwrap_or_default(), })), TimeUnit::None => Err(ParseError::abrupt_end("TimeDurationDesignator")), } } + +fn adjust_fraction_for_unit(fraction: Fraction, unit: u64) -> ParserResult { + let value = fraction + .value + .checked_mul(unit) + .ok_or(ParseError::DurationFractionExceededRange)?; + Ok(Fraction { + digits: fraction.digits, + value, + }) +} diff --git a/utils/ixdtf/src/parsers/mod.rs b/utils/ixdtf/src/parsers/mod.rs index df21c1688b9..2b7ea8e18b7 100644 --- a/utils/ixdtf/src/parsers/mod.rs +++ b/utils/ixdtf/src/parsers/mod.rs @@ -236,7 +236,7 @@ impl<'a> IxdtfParser<'a> { /// # Example /// /// ```rust -/// use ixdtf::parsers::{IsoDurationParser, records::{Sign, DurationParseRecord, TimeDurationRecord}}; +/// use ixdtf::parsers::{IsoDurationParser, records::{Sign, DurationParseRecord, Fraction, TimeDurationRecord}}; /// /// let duration_str = "P1Y2M1DT2H10M30S"; /// @@ -245,13 +245,13 @@ impl<'a> IxdtfParser<'a> { /// let date_duration = result.date.unwrap(); /// /// let (hours, minutes, seconds, fraction) = match result.time { -/// // Hours variant is defined as { hours: u32, fraction: u64 } +/// // Hours variant is defined as { hours: u32, fraction: Fraction } /// Some(TimeDurationRecord::Hours{ hours, fraction }) => (hours, 0, 0, fraction), -/// // Minutes variant is defined as { hours: u32, minutes: u32, fraction: u64 } +/// // Minutes variant is defined as { hours: u32, minutes: u32, fraction: Fraction } /// Some(TimeDurationRecord::Minutes{ hours, minutes, fraction }) => (hours, minutes, 0, fraction), -/// // Seconds variant is defined as { hours: u32, minutes: u32, seconds: u32, fraction: u32 } -/// Some(TimeDurationRecord::Seconds{ hours, minutes, seconds, fraction }) => (hours, minutes, seconds, fraction as u64), -/// None => (0,0,0,0), +/// // Seconds variant is defined as { hours: u32, minutes: u32, seconds: u32, fraction: Fraction } +/// Some(TimeDurationRecord::Seconds{ hours, minutes, seconds, fraction }) => (hours, minutes, seconds, fraction), +/// None => (0,0,0, Fraction::default()), /// }; /// /// assert_eq!(result.sign, Sign::Positive); @@ -262,7 +262,7 @@ impl<'a> IxdtfParser<'a> { /// assert_eq!(hours, 2); /// assert_eq!(minutes, 10); /// assert_eq!(seconds, 30); -/// assert_eq!(fraction, 0); +/// assert_eq!(fraction, Fraction { digits: 0, value: 0 }); /// ``` #[cfg(feature = "duration")] #[derive(Debug)] @@ -313,7 +313,7 @@ impl<'a> IsoDurationParser<'a> { /// ## Parsing a time duration /// /// ```rust - /// # use ixdtf::parsers::{IsoDurationParser, records::{DurationParseRecord, TimeDurationRecord }}; + /// # use ixdtf::parsers::{IsoDurationParser, records::{DurationParseRecord, Fraction, TimeDurationRecord }}; /// let time_duration = "PT2H10M30S"; /// /// let result = IsoDurationParser::from_str(time_duration).parse().unwrap(); @@ -324,14 +324,14 @@ impl<'a> IsoDurationParser<'a> { /// // Minutes variant is defined as { hours: u32, minutes: u32, fraction: u64 } /// Some(TimeDurationRecord::Minutes{ hours, minutes, fraction }) => (hours, minutes, 0, fraction), /// // Seconds variant is defined as { hours: u32, minutes: u32, seconds: u32, fraction: u32 } - /// Some(TimeDurationRecord::Seconds{ hours, minutes, seconds, fraction }) => (hours, minutes, seconds, fraction as u64), - /// None => (0,0,0,0), + /// Some(TimeDurationRecord::Seconds{ hours, minutes, seconds, fraction }) => (hours, minutes, seconds, fraction), + /// None => (0,0,0,Fraction::default()), /// }; /// assert!(result.date.is_none()); /// assert_eq!(hours, 2); /// assert_eq!(minutes, 10); /// assert_eq!(seconds, 30); - /// assert_eq!(fraction, 0); + /// assert_eq!(fraction, Fraction { digits: 0, value: 0 }); /// ``` pub fn parse(&mut self) -> ParserResult { duration::parse_duration(&mut self.cursor) diff --git a/utils/ixdtf/src/parsers/records.rs b/utils/ixdtf/src/parsers/records.rs index 8377b5f97c8..48ed5621124 100644 --- a/utils/ixdtf/src/parsers/records.rs +++ b/utils/ixdtf/src/parsers/records.rs @@ -55,7 +55,7 @@ pub struct TimeRecord { /// A second value. pub second: u8, /// A nanosecond value representing all sub-second components. - pub nanosecond: u32, + pub fraction: Fraction, } /// A `TimeZoneAnnotation` that represents a parsed `TimeZoneRecord` and its critical flag. @@ -111,7 +111,7 @@ pub struct UtcOffsetRecord { /// The second value of the `UtcOffsetRecord`. pub second: u8, /// Any nanosecond value of the `UTCOffsetRecord`. - pub nanosecond: u32, + pub fraction: Fraction, } impl UtcOffsetRecord { @@ -122,7 +122,10 @@ impl UtcOffsetRecord { hour: 0, minute: 0, second: 0, - nanosecond: 0, + fraction: Fraction { + digits: 0, + value: 0, + }, } } } @@ -144,7 +147,7 @@ impl UtcOffsetRecordOrZ { hour: 0, minute: 0, second: 0, - nanosecond: 0, + fraction: Fraction::default(), }, } } @@ -191,7 +194,7 @@ pub enum TimeDurationRecord { /// Hours value. hours: u64, /// The parsed fraction value in nanoseconds. - fraction: u64, + fraction: Fraction, }, // A Minutes Time duration record. Minutes { @@ -200,7 +203,7 @@ pub enum TimeDurationRecord { /// Minutes value. minutes: u64, /// The parsed fraction value in nanoseconds. - fraction: u64, + fraction: Fraction, }, // A Seconds Time duration record. Seconds { @@ -211,6 +214,46 @@ pub enum TimeDurationRecord { /// Seconds value. seconds: u64, /// The parsed fraction value in nanoseconds. - fraction: u32, + fraction: Fraction, }, } + +/// A fraction value in nanoseconds or lower value. +/// +/// # Precision note +/// +/// `ixdtf` parses a fraction value to a precision of 18 digits of precision, but +/// preserves the fraction's digit length +#[derive(Debug, Default, Clone, Copy, PartialEq)] +#[allow(clippy::exhaustive_structs)] // A fraction is only a value and its digit length. +pub struct Fraction { + // The count of fraction digits, i.e. the fraction's digit length. + pub digits: u8, + // The parsed fraction value. + pub value: u64, +} + +impl Fraction { + /// Returns Some(`u32`) representing the `Fraction` as it's computed + /// nanosecond value or `None` if the digits exceeds 9 digits. + pub fn to_nanoseconds(&self) -> Option { + if self.digits <= 9 { + 10u32 + .checked_pow(9 - u32::from(self.digits)) + .map(|x| x * self.value as u32) + } else { + None + } + } + + /// Returns a `u64` representing the `Fraction` as it's computed + /// nanosecond value, truncating any value beyond 9 digits to + /// nanoseconds + pub fn to_truncated_nanoseconds(&self) -> u64 { + if self.digits <= 9 { + self.value + } else { + self.value / 10u64.pow(u32::from(self.digits - 9)) + } + } +} diff --git a/utils/ixdtf/src/parsers/tests.rs b/utils/ixdtf/src/parsers/tests.rs index 6336e0a3f1f..12fd81f5345 100644 --- a/utils/ixdtf/src/parsers/tests.rs +++ b/utils/ixdtf/src/parsers/tests.rs @@ -8,7 +8,7 @@ use alloc::vec::Vec; use crate::{ parsers::{ records::{ - Annotation, DateRecord, IxdtfParseRecord, TimeRecord, TimeZoneAnnotation, + Annotation, DateRecord, Fraction, IxdtfParseRecord, TimeRecord, TimeZoneAnnotation, TimeZoneRecord, UtcOffsetRecordOrZ, }, IxdtfParser, @@ -46,7 +46,10 @@ fn temporal_date_time_max() { hour: 12, minute: 28, second: 32, - nanosecond: 329402834, + fraction: Fraction { + digits: 9, + value: 329402834 + } }) ); } @@ -573,7 +576,10 @@ fn temporal_duration_parsing() { hours: 1, minutes: 1, seconds: 1, - fraction: 123456789 + fraction: Fraction { + digits: 9, + value: 123456789, + } }) }, "Failing to parse a valid Duration string: \"{}\" should pass.", @@ -593,7 +599,10 @@ fn temporal_duration_parsing() { }), time: Some(TimeDurationRecord::Hours { hours: 0, - fraction: 1_800_000_000_000, + fraction: Fraction { + digits: 1, + value: 18_000 + }, }) } ); @@ -604,12 +613,7 @@ fn temporal_duration_parsing() { fn temporal_invalid_durations() { use crate::parsers::IsoDurationParser; - let invalids = [ - "P1Y1M1W0,5D", - "P1Y1M1W1DT1H1M1.123456789123S", - "+PT", - "P1Y1M1W1DT1H0.5M0.5S", - ]; + let invalids = ["P1Y1M1W0,5D", "+PT", "P1Y1M1W1DT1H0.5M0.5S"]; for test in invalids { let err = IsoDurationParser::from_str(test).parse(); @@ -638,6 +642,32 @@ fn maximum_duration_fraction() { assert!(result.is_ok()); } +#[test] +#[cfg(feature = "duration")] +fn duration_fraction_extended() { + use crate::parsers::{ + records::{DurationParseRecord, Sign, TimeDurationRecord}, + IsoDurationParser, + }; + let test = "PT1H1.123456789123M"; + let result = IsoDurationParser::from_str(test).parse(); + assert_eq!( + result, + Ok(DurationParseRecord { + sign: Sign::Positive, + date: None, + time: Some(TimeDurationRecord::Minutes { + hours: 1, + minutes: 1, + fraction: Fraction { + digits: 12, + value: 7_407_407_347_380, + } + }) + }) + ); +} + #[test] #[cfg(feature = "duration")] fn duration_exceeds_range() { @@ -781,7 +811,10 @@ fn test_correct_datetime() { hour: 4, minute: 0, second: 0, - nanosecond: 0, + fraction: Fraction { + digits: 0, + value: 0 + }, }), offset: None, tz: None, @@ -803,7 +836,10 @@ fn test_correct_datetime() { hour: 4, minute: 34, second: 0, - nanosecond: 0, + fraction: Fraction { + digits: 0, + value: 0 + }, }), offset: None, tz: None, @@ -825,7 +861,10 @@ fn test_correct_datetime() { hour: 4, minute: 34, second: 22, - nanosecond: 0, + fraction: Fraction { + digits: 0, + value: 0 + }, }), offset: None, tz: None, @@ -847,7 +886,10 @@ fn test_correct_datetime() { hour: 4, minute: 34, second: 22, - nanosecond: 0, + fraction: Fraction { + digits: 3, + value: 0 + }, }), offset: None, tz: None, @@ -869,7 +911,10 @@ fn test_correct_datetime() { hour: 4, minute: 34, second: 22, - nanosecond: 0, + fraction: Fraction { + digits: 3, + value: 0 + }, }), offset: None, tz: None, @@ -974,7 +1019,10 @@ fn test_zulu_offset() { hour: 14, minute: 0, second: 0, - nanosecond: 0, + fraction: Fraction { + digits: 0, + value: 0 + }, }), offset: Some(crate::parsers::records::UtcOffsetRecordOrZ::Z), tz: Some(TimeZoneAnnotation { @@ -999,7 +1047,10 @@ fn test_zulu_offset() { hour: 14, minute: 0, second: 0, - nanosecond: 0, + fraction: Fraction { + digits: 0, + value: 0 + }, }), offset: Some(UtcOffsetRecordOrZ::Z), tz: None, @@ -1011,6 +1062,10 @@ fn test_zulu_offset() { // Examples referenced from #[test] fn subsecond_string_tests() { + let hanging_subsecond_start = "15:23:30."; + let err = IxdtfParser::from_str(hanging_subsecond_start).parse_time(); + assert!(err.is_err()); + let subsecond_time = "2025-01-15T15:23:30.1"; let result = IxdtfParser::from_str(subsecond_time).parse(); assert_eq!( @@ -1025,7 +1080,10 @@ fn subsecond_string_tests() { hour: 15, minute: 23, second: 30, - nanosecond: 100_000_000, + fraction: Fraction { + digits: 1, + value: 1, + }, }), offset: None, tz: None, @@ -1047,7 +1105,10 @@ fn subsecond_string_tests() { hour: 15, minute: 23, second: 30, - nanosecond: 123_456_780, + fraction: Fraction { + digits: 8, + value: 12_345_678 + }, }), offset: None, tz: None, @@ -1069,7 +1130,176 @@ fn subsecond_string_tests() { hour: 15, minute: 23, second: 30, - nanosecond: 123_456_789, + fraction: Fraction { + digits: 9, + value: 123_456_789, + }, + }), + offset: None, + tz: None, + calendar: None, + }) + ); +} + +#[test] +fn subseconds_parsing_extended_nanoseconds() { + // Test nanoseconds unbalanced + let subsecond_time = "15:23:30.1234567"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 7, + value: 1_234_567 + }, + }), + offset: None, + tz: None, + calendar: None, + }) + ); + + // Test nanoseconds + let subsecond_time = "15:23:30.123456789"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 9, + value: 123_456_789, + }, + }), + offset: None, + tz: None, + calendar: None, + }) + ); +} + +#[test] +fn subseconds_parsing_extended_picoseconds() { + // Test picoseconds unbalanced + let subsecond_time = "15:23:30.1234567890"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 10, + value: 1_234_567_890, + } + }), + offset: None, + tz: None, + calendar: None, + }) + ); + + // Test picoseconds + let subsecond_time = "15:23:30.123456789876"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 12, + value: 123_456_789_876, + } + }), + offset: None, + tz: None, + calendar: None, + }) + ); +} + +#[test] +fn subseconds_parsing_extended_femtoseconds() { + // Test femtoseconds unbalanced + let subsecond_time = "15:23:30.1234567898765"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 13, + value: 1_234_567_898_765, + } + }), + offset: None, + tz: None, + calendar: None, + }) + ); + + // Test femtoseconds + let subsecond_time = "15:23:30.123456789876543"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 15, + value: 123_456_789_876_543 + } + }), + offset: None, + tz: None, + calendar: None, + }) + ); +} + +#[test] +fn subseconds_parsing_extended_truncated() { + // Test truncated unbalanced + let subsecond_time = "15:23:30.1234567898765432"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 16, + value: 1_234_567_898_765_432 + }, }), offset: None, tz: None, @@ -1077,7 +1307,25 @@ fn subsecond_string_tests() { }) ); - let subsecond_time = "1976-11-18T15:23:30.1234567890"; - let err = IxdtfParser::from_str(subsecond_time).parse(); - assert_eq!(err, Err(ParseError::FractionPart)); + // Test truncation + let subsecond_time = "15:23:30.1234567898765432101234567890987654321"; + let result = IxdtfParser::from_str(subsecond_time).parse_time(); + assert_eq!( + result, + Ok(IxdtfParseRecord { + date: None, + time: Some(TimeRecord { + hour: 15, + minute: 23, + second: 30, + fraction: Fraction { + digits: 37, + value: 123_456_789_876_543_210, + }, + }), + offset: None, + tz: None, + calendar: None, + }) + ); } diff --git a/utils/ixdtf/src/parsers/time.rs b/utils/ixdtf/src/parsers/time.rs index bf6f537956b..08e46a3c24a 100644 --- a/utils/ixdtf/src/parsers/time.rs +++ b/utils/ixdtf/src/parsers/time.rs @@ -18,7 +18,10 @@ use crate::{ ParseError, ParserResult, }; -use super::{annotations, records::IxdtfParseRecord}; +use super::{ + annotations, + records::{Fraction, IxdtfParseRecord}, +}; /// Parse annotated time record is silently fallible returning None in the case that the /// value does not align @@ -75,7 +78,7 @@ pub(crate) fn parse_time_record(cursor: &mut Cursor) -> ParserResult hour, minute: 0, second: 0, - nanosecond: 0, + fraction: Fraction::default(), }); } @@ -89,7 +92,7 @@ pub(crate) fn parse_time_record(cursor: &mut Cursor) -> ParserResult hour, minute, second: 0, - nanosecond: 0, + fraction: Fraction::default(), }); } @@ -99,13 +102,13 @@ pub(crate) fn parse_time_record(cursor: &mut Cursor) -> ParserResult let second = parse_minute_second(cursor, true)?; - let nanosecond = parse_fraction(cursor)?.unwrap_or(0); + let fraction = parse_fraction(cursor)?.unwrap_or_default(); Ok(TimeRecord { hour, minute, second, - nanosecond, + fraction, }) } @@ -141,28 +144,26 @@ pub(crate) fn parse_minute_second(cursor: &mut Cursor, is_second: bool) -> Parse /// This is primarily used in ISO8601 to add percision past /// a second. #[inline] -pub(crate) fn parse_fraction(cursor: &mut Cursor) -> ParserResult> { +pub(crate) fn parse_fraction(cursor: &mut Cursor) -> ParserResult> { // Assert that the first char provided is a decimal separator. if !cursor.check_or(false, is_decimal_separator) { return Ok(None); } cursor.next_or(ParseError::FractionPart)?; - let mut result = 0; - let mut fraction_len = 0; + let mut value = 0; + let mut digits: u8 = 0; while cursor.check_or(false, |ch| ch.is_ascii_digit()) { - if fraction_len >= 9 { - return Err(ParseError::FractionPart); + let next_value = u64::from(cursor.next_digit()?.ok_or(ParseError::ImplAssert)?); + if digits < 18 { + value = value * 10 + next_value; } - result = result * 10 + u32::from(cursor.next_digit()?.ok_or(ParseError::FractionPart)?); - fraction_len += 1; + digits = digits.saturating_add(1); } - // Assert: 10^9-1 should always be a valid u32. - let result = result - * 10u32 - .checked_pow(9 - fraction_len) - .ok_or(ParseError::ImplAssert)?; + if digits == 0 { + return Err(ParseError::FractionPart); + } - Ok(Some(result)) + Ok(Some(Fraction { digits, value })) } diff --git a/utils/ixdtf/src/parsers/timezone.rs b/utils/ixdtf/src/parsers/timezone.rs index 308ff1c3c5e..084f849593e 100644 --- a/utils/ixdtf/src/parsers/timezone.rs +++ b/utils/ixdtf/src/parsers/timezone.rs @@ -10,7 +10,9 @@ use super::{ is_annotation_key_value_separator, is_annotation_open, is_critical_flag, is_sign, is_time_separator, is_tz_char, is_tz_leading_char, is_tz_name_separator, is_utc_designator, }, - records::{Sign, TimeZoneAnnotation, TimeZoneRecord, UtcOffsetRecord, UtcOffsetRecordOrZ}, + records::{ + Fraction, Sign, TimeZoneAnnotation, TimeZoneRecord, UtcOffsetRecord, UtcOffsetRecordOrZ, + }, time::{parse_fraction, parse_hour, parse_minute_second}, Cursor, }; @@ -155,9 +157,9 @@ pub(crate) fn parse_date_time_utc(cursor: &mut Cursor) -> ParserResult