From 19120acaf47945e98473a4c48e504cd98939a51d Mon Sep 17 00:00:00 2001 From: Jacob Pratt Date: Tue, 15 Oct 2024 05:30:10 -0400 Subject: [PATCH] Support century when parsing/formatting year --- tests/formatting.rs | 1 + tests/meta.rs | 2 +- tests/parse_format_description.rs | 1 + tests/parsing.rs | 30 +++- tests/quickcheck.rs | 28 ++- .../src/format_description/format_item.rs | 1 + .../src/format_description/public/modifier.rs | 1 + time/src/format_description/modifier.rs | 2 + .../format_description/parse/format_item.rs | 1 + time/src/formatting/mod.rs | 7 +- time/src/parsing/component.rs | 62 +++++-- time/src/parsing/parsed.rs | 170 ++++++++++++++++-- 12 files changed, 275 insertions(+), 31 deletions(-) diff --git a/tests/formatting.rs b/tests/formatting.rs index 6cb403b74..53c5a2e61 100644 --- a/tests/formatting.rs +++ b/tests/formatting.rs @@ -391,6 +391,7 @@ fn format_date() -> time::Result<()> { (fd!("[year base:iso_week]"), "2020"), (fd!("[year sign:mandatory]"), "+2019"), (fd!("[year base:iso_week sign:mandatory]"), "+2020"), + (fd!("[year repr:century]"), "20"), (fd!("[year repr:last_two]"), "19"), (fd!("[year base:iso_week repr:last_two]"), "20"), ]; diff --git a/tests/meta.rs b/tests/meta.rs index ded83b616..2ca688222 100644 --- a/tests/meta.rs +++ b/tests/meta.rs @@ -154,7 +154,7 @@ fn size() { assert_size!(iso8601::FormattedComponents, 1, 1); assert_size!(iso8601::OffsetPrecision, 1, 1); assert_size!(iso8601::TimePrecision, 2, 2); - assert_size!(Parsed, 56, 56); + assert_size!(Parsed, 64, 64); assert_size!(Month, 1, 1); assert_size!(Weekday, 1, 1); assert_size!(Error, 56, 56); diff --git a/tests/parse_format_description.rs b/tests/parse_format_description.rs index f29233c93..d9d7fa777 100644 --- a/tests/parse_format_description.rs +++ b/tests/parse_format_description.rs @@ -78,6 +78,7 @@ fn modifiers( week_number_repr: _, #[values( (YearRepr::Full, "repr:full"), + (YearRepr::Century, "repr:century"), (YearRepr::LastTwo, "repr:last_two"), )] year_repr: _, diff --git a/tests/parsing.rs b/tests/parsing.rs index 518661d3b..749ea95fb 100644 --- a/tests/parsing.rs +++ b/tests/parsing.rs @@ -867,8 +867,8 @@ fn parse_date() -> time::Result<()> { "[year padding:space]-W[week_number repr:sunday padding:none]-[weekday \ repr:sunday]", )?, - " 2018-W01-2", - date!(2018 - 01 - 02), + " 201-W01-2", + date!(201 - 01 - 06), ), ]; @@ -1124,10 +1124,10 @@ fn parse_offset_date_time_err() -> time::Result<()> { #[test] fn parse_components() -> time::Result<()> { macro_rules! parse_component { - ($component:expr, $input:expr,_. $property:ident() == $expected:expr) => { + ($component:expr, $input:expr, $(_. $property:ident() == $expected:expr);+ $(;)?) => { let mut parsed = Parsed::new(); parsed.parse_component($input, $component)?; - assert_eq!(parsed.$property(), $expected); + $(assert_eq!(parsed.$property(), $expected);)+ }; } @@ -1141,6 +1141,17 @@ fn parse_components() -> time::Result<()> { b"2021", _.year() == Some(2021) ); + parse_component!( + Component::Year(modifier!(Year { + padding: modifier::Padding::Zero, + repr: modifier::YearRepr::Century, + iso_week_based: false, + sign_is_mandatory: false, + })), + b"20", + _.year_century() == Some(20); + _.year_century_is_negative() == Some(false); + ); parse_component!( Component::Year(modifier!(Year { padding: modifier::Padding::Zero, @@ -1161,6 +1172,17 @@ fn parse_components() -> time::Result<()> { b"2021", _.iso_year() == Some(2021) ); + parse_component!( + Component::Year(modifier!(Year { + padding: modifier::Padding::Zero, + repr: modifier::YearRepr::Century, + iso_week_based: true, + sign_is_mandatory: false, + })), + b"20", + _.iso_year_century() == Some(20); + _.iso_year_century_is_negative() == Some(false); + ); parse_component!( Component::Year(modifier!(Year { padding: modifier::Padding::Zero, diff --git a/tests/quickcheck.rs b/tests/quickcheck.rs index 5823111b1..e0b7ee6cb 100644 --- a/tests/quickcheck.rs +++ b/tests/quickcheck.rs @@ -1,7 +1,7 @@ use num_conv::prelude::*; use quickcheck::{Arbitrary, TestResult}; use quickcheck_macros::quickcheck; -use time::macros::time; +use time::macros::{format_description, time}; use time::Weekday::*; use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset, Weekday}; @@ -56,6 +56,32 @@ fn date_ywd_roundtrip(d: Date) -> bool { Date::from_iso_week_date(year, week, weekday) == Ok(d) } +#[quickcheck] +fn date_format_century_last_two_equivalent(d: Date) -> bool { + let split_format = format_description!("[year repr:century][year repr:last_two]-[month]-[day]"); + let split = d.format(&split_format).expect("formatting failed"); + + let combined_format = format_description!("[year]-[month]-[day]"); + let combined = d.format(&combined_format).expect("formatting failed"); + + split == combined +} + +#[quickcheck] +fn date_parse_century_last_two_equivalent(d: Date) -> TestResult { + // There is an ambiguity when parsing a year with fewer than six digits, as the first four are + // consumed by the century, leaving at most one for the last two digits. + if !matches!(d.year().unsigned_abs().to_string().len(), 6) { + return TestResult::discard(); + } + + let split_format = format_description!("[year repr:century][year repr:last_two]-[month]-[day]"); + let combined_format = format_description!("[year]-[month]-[day]"); + let combined = d.format(&combined_format).expect("formatting failed"); + + TestResult::from_bool(Date::parse(&combined, &split_format).expect("parsing failed") == d) +} + #[quickcheck] fn julian_day_roundtrip(d: Date) -> bool { Date::from_julian_day(d.to_julian_day()) == Ok(d) diff --git a/time-macros/src/format_description/format_item.rs b/time-macros/src/format_description/format_item.rs index ea36caee0..6c27a42c4 100644 --- a/time-macros/src/format_description/format_item.rs +++ b/time-macros/src/format_description/format_item.rs @@ -425,6 +425,7 @@ modifier! { enum YearRepr { #[default] Full = b"full", + Century = b"century", LastTwo = b"last_two", } } diff --git a/time-macros/src/format_description/public/modifier.rs b/time-macros/src/format_description/public/modifier.rs index 63bfaa706..a91bad339 100644 --- a/time-macros/src/format_description/public/modifier.rs +++ b/time-macros/src/format_description/public/modifier.rs @@ -131,6 +131,7 @@ to_tokens! { to_tokens! { pub(crate) enum YearRepr { Full, + Century, LastTwo, } } diff --git a/time/src/format_description/modifier.rs b/time/src/format_description/modifier.rs index 3a57bb6f6..e21353c40 100644 --- a/time/src/format_description/modifier.rs +++ b/time/src/format_description/modifier.rs @@ -101,6 +101,8 @@ pub struct WeekNumber { pub enum YearRepr { /// The full value of the year. Full, + /// All digits except the last two. Includes the sign, if any. + Century, /// Only the last two digits of the year. LastTwo, } diff --git a/time/src/format_description/parse/format_item.rs b/time/src/format_description/parse/format_item.rs index 2e1ee6a74..7a75006c6 100644 --- a/time/src/format_description/parse/format_item.rs +++ b/time/src/format_description/parse/format_item.rs @@ -521,6 +521,7 @@ modifier! { enum YearRepr { #[default] Full = b"full", + Century = b"century", LastTwo = b"last_two", } } diff --git a/time/src/formatting/mod.rs b/time/src/formatting/mod.rs index d4a1ce60e..23dd220d3 100644 --- a/time/src/formatting/mod.rs +++ b/time/src/formatting/mod.rs @@ -308,6 +308,7 @@ fn fmt_year( }; let value = match repr { modifier::YearRepr::Full => full_year, + modifier::YearRepr::Century => full_year / 100, modifier::YearRepr::LastTwo => (full_year % 100).abs(), }; let format_number = match repr { @@ -316,7 +317,11 @@ fn fmt_year( #[cfg(feature = "large-dates")] modifier::YearRepr::Full if value.abs() >= 10_000 => format_number::<5>, modifier::YearRepr::Full => format_number::<4>, - modifier::YearRepr::LastTwo => format_number::<2>, + #[cfg(feature = "large-dates")] + modifier::YearRepr::Century if value.abs() >= 1_000 => format_number::<4>, + #[cfg(feature = "large-dates")] + modifier::YearRepr::Century if value.abs() >= 100 => format_number::<3>, + modifier::YearRepr::Century | modifier::YearRepr::LastTwo => format_number::<2>, }; let mut bytes = 0; if repr != modifier::YearRepr::LastTwo { diff --git a/time/src/parsing/component.rs b/time/src/parsing/component.rs index e2a7e7c0d..5fa47ca20 100644 --- a/time/src/parsing/component.rs +++ b/time/src/parsing/component.rs @@ -16,24 +16,62 @@ use crate::{Month, Weekday}; // region: date components /// Parse the "year" component of a `Date`. -pub(crate) fn parse_year(input: &[u8], modifiers: modifier::Year) -> Option> { +pub(crate) fn parse_year( + input: &[u8], + modifiers: modifier::Year, +) -> Option> { match modifiers.repr { modifier::YearRepr::Full => { let ParsedItem(input, sign) = opt(sign)(input); - #[cfg(not(feature = "large-dates"))] - let ParsedItem(input, year) = - exactly_n_digits_padded::<4, u32>(modifiers.padding)(input)?; - #[cfg(feature = "large-dates")] - let ParsedItem(input, year) = - n_to_m_digits_padded::<4, 6, u32>(modifiers.padding)(input)?; - match sign { - Some(b'-') => Some(ParsedItem(input, -year.cast_signed())), - None if modifiers.sign_is_mandatory || year >= 10_000 => None, - _ => Some(ParsedItem(input, year.cast_signed())), + + if let Some(sign) = sign { + #[cfg(not(feature = "large-dates"))] + let ParsedItem(input, year) = + exactly_n_digits_padded::<4, u32>(modifiers.padding)(input)?; + #[cfg(feature = "large-dates")] + let ParsedItem(input, year) = + n_to_m_digits_padded::<4, 6, u32>(modifiers.padding)(input)?; + + Some(if sign == b'-' { + ParsedItem(input, (-year.cast_signed(), true)) + } else { + ParsedItem(input, (year.cast_signed(), false)) + }) + } else if modifiers.sign_is_mandatory { + None + } else { + let ParsedItem(input, year) = + exactly_n_digits_padded::<4, u32>(modifiers.padding)(input)?; + Some(ParsedItem(input, (year.cast_signed(), false))) + } + } + modifier::YearRepr::Century => { + let ParsedItem(input, sign) = opt(sign)(input); + + if let Some(sign) = sign { + #[cfg(not(feature = "large-dates"))] + let ParsedItem(input, year) = + exactly_n_digits_padded::<2, u32>(modifiers.padding)(input)?; + #[cfg(feature = "large-dates")] + let ParsedItem(input, year) = + n_to_m_digits_padded::<2, 4, u32>(modifiers.padding)(input)?; + + Some(if sign == b'-' { + ParsedItem(input, (-year.cast_signed(), true)) + } else { + ParsedItem(input, (year.cast_signed(), false)) + }) + } else if modifiers.sign_is_mandatory { + None + } else { + let ParsedItem(input, year) = + exactly_n_digits_padded::<2, u32>(modifiers.padding)(input)?; + Some(ParsedItem(input, (year.cast_signed(), false))) } } modifier::YearRepr::LastTwo => Some( - exactly_n_digits_padded::<2, u32>(modifiers.padding)(input)?.map(|v| v.cast_signed()), + exactly_n_digits_padded::<2, u32>(modifiers.padding)(input)? + .map(|v| (v.cast_signed(), false)), ), } } diff --git a/time/src/parsing/parsed.rs b/time/src/parsing/parsed.rs index 3c6c4f511..bc1526e2d 100644 --- a/time/src/parsing/parsed.rs +++ b/time/src/parsing/parsed.rs @@ -3,8 +3,9 @@ use core::num::{NonZeroU16, NonZeroU8}; use deranged::{ - OptionRangedI128, OptionRangedI32, OptionRangedI8, OptionRangedU16, OptionRangedU32, - OptionRangedU8, RangedI128, RangedI32, RangedI8, RangedU16, RangedU32, RangedU8, + OptionRangedI128, OptionRangedI16, OptionRangedI32, OptionRangedI8, OptionRangedU16, + OptionRangedU32, OptionRangedU8, RangedI128, RangedI16, RangedI32, RangedI8, RangedU16, + RangedU32, RangedU8, }; use num_conv::prelude::*; @@ -115,10 +116,14 @@ impl sealed::AnyFormatItem for OwnedFormatItem { pub struct Parsed { /// Calendar year. year: OptionRangedI32<{ MIN_YEAR }, { MAX_YEAR }>, + /// All digits except the last two of the calendar year. + year_century: OptionRangedI16<{ (MIN_YEAR / 100) as i16 }, { (MAX_YEAR / 100) as i16 }>, /// The last two digits of the calendar year. year_last_two: OptionRangedU8<0, 99>, /// Year of the [ISO week date](https://en.wikipedia.org/wiki/ISO_week_date). iso_year: OptionRangedI32<{ MIN_YEAR }, { MAX_YEAR }>, + /// All digits except the last two of the ISO week year. + iso_year_century: OptionRangedI16<{ (MIN_YEAR / 100) as i16 }, { (MAX_YEAR / 100) as i16 }>, /// The last two digits of the ISO week year. iso_year_last_two: OptionRangedU8<0, 99>, /// Month of the year. @@ -143,7 +148,6 @@ pub struct Parsed { /// Whether the `hour_12` field indicates a time that "PM". hour_12_is_pm: Option, /// Minute within the hour. - // minute: MaybeUninit, minute: OptionRangedU8<0, { Minute::per(Hour) - 1 }>, /// Second within the minute. // do not subtract one, as leap seconds may be allowed @@ -171,7 +175,13 @@ pub struct Parsed { >, /// Indicates whether the [`UtcOffset`] is negative. This information is obtained when parsing /// the offset hour, but may not otherwise be stored due to "-0" being equivalent to "0". - offset_is_negative: Option, + offset_is_negative: bool, + /// Indicates whether the `year_century` component is negative. This information is obtained + /// when parsing, but may not otherwise be stored due to "-0" being equivalent to "0". + year_century_is_negative: bool, + /// Indicates whether the `iso_year_century` component is negative. This information is + /// obtained when parsing, but may not otherwise be stored due to "-0" being equivalent to "0". + iso_year_century_is_negative: bool, /// Indicates whether a leap second is permitted to be parsed. This is required by some /// well-known formats. pub(super) leap_second_allowed: bool, @@ -188,8 +198,10 @@ impl Parsed { pub const fn new() -> Self { Self { year: OptionRangedI32::None, + year_century: OptionRangedI16::None, year_last_two: OptionRangedU8::None, iso_year: OptionRangedI32::None, + iso_year_century: OptionRangedI16::None, iso_year_last_two: OptionRangedU8::None, month: None, sunday_week_number: OptionRangedU8::None, @@ -208,7 +220,9 @@ impl Parsed { offset_minute: OptionRangedI8::None, offset_second: OptionRangedI8::None, unix_timestamp_nanos: OptionRangedI128::None, - offset_is_negative: None, + offset_is_negative: false, + year_century_is_negative: false, + iso_year_century_is_negative: false, leap_second_allowed: false, } } @@ -292,14 +306,20 @@ impl Parsed { Ok(remaining) } Component::Year(modifiers) => { - let ParsedItem(remaining, value) = + let ParsedItem(remaining, (value, is_negative)) = parse_year(input, modifiers).ok_or(InvalidComponent("year"))?; match (modifiers.iso_week_based, modifiers.repr) { (false, modifier::YearRepr::Full) => self.set_year(value), + (false, modifier::YearRepr::Century) => { + self.set_year_century(value.truncate(), is_negative) + } (false, modifier::YearRepr::LastTwo) => { self.set_year_last_two(value.cast_unsigned().truncate()) } (true, modifier::YearRepr::Full) => self.set_iso_year(value), + (true, modifier::YearRepr::Century) => { + self.set_iso_year_century(value.truncate(), is_negative) + } (true, modifier::YearRepr::LastTwo) => { self.set_iso_year_last_two(value.cast_unsigned().truncate()) } @@ -336,7 +356,7 @@ impl Parsed { .and_then(|parsed| { parsed.consume_value(|(value, is_negative)| { self.set_offset_hour(value)?; - self.offset_is_negative = Some(is_negative); + self.offset_is_negative = is_negative; Some(()) }) }) @@ -373,6 +393,25 @@ impl Parsed { self.year.get_primitive() } + /// Obtain the `year_century` component. + /// + /// If the year is zero, the sign of the century is not stored. To differentiate between + /// positive and negative zero, use `year_century_is_negative`. + pub const fn year_century(&self) -> Option { + self.year_century.get_primitive() + } + + /// Obtain the `year_century_is_negative` component. + /// + /// This indicates whether the value returned from `year_century` is negative. If the year is + /// zero, it is necessary to call this method for disambiguation. + pub const fn year_century_is_negative(&self) -> Option { + match self.year_century() { + Some(_) => Some(self.year_century_is_negative), + None => None, + } + } + /// Obtain the `year_last_two` component. pub const fn year_last_two(&self) -> Option { self.year_last_two.get_primitive() @@ -383,6 +422,25 @@ impl Parsed { self.iso_year.get_primitive() } + /// Obtain the `iso_year_century` component. + /// + /// If the year is zero, the sign of the century is not stored. To differentiate between + /// positive and negative zero, use `iso_year_century_is_negative`. + pub const fn iso_year_century(&self) -> Option { + self.iso_year_century.get_primitive() + } + + /// Obtain the `iso_year_century_is_negative` component. + /// + /// This indicates whether the value returned from `iso_year_century` is negative. If the year + /// is zero, it is necessary to call this method for disambiguation. + pub const fn iso_year_century_is_negative(&self) -> Option { + match self.iso_year_century() { + Some(_) => Some(self.iso_year_century_is_negative), + None => None, + } + } + /// Obtain the `iso_year_last_two` component. pub const fn iso_year_last_two(&self) -> Option { self.iso_year_last_two.get_primitive() @@ -468,7 +526,7 @@ impl Parsed { /// Obtain the `offset_minute` component. pub const fn offset_minute_signed(&self) -> Option { match (self.offset_minute.get_primitive(), self.offset_is_negative) { - (Some(offset_minute), Some(true)) => Some(-offset_minute), + (Some(offset_minute), true) => Some(-offset_minute), (Some(offset_minute), _) => Some(offset_minute), (None, _) => None, } @@ -484,7 +542,7 @@ impl Parsed { /// Obtain the `offset_second` component. pub const fn offset_second_signed(&self) -> Option { match (self.offset_second.get_primitive(), self.offset_is_negative) { - (Some(offset_second), Some(true)) => Some(-offset_second), + (Some(offset_second), true) => Some(-offset_second), (Some(offset_second), _) => Some(offset_second), (None, _) => None, } @@ -514,9 +572,43 @@ macro_rules! setters { impl Parsed { setters! { year set_year with_year i32; + } + + /// Set the `year_century` component. + /// + /// If the value is zero, the sign of the century is taken from the second parameter. Otherwise + /// the sign is inferred from the value. + pub fn set_year_century(&mut self, value: i16, is_negative: bool) -> Option<()> { + self.year_century = OptionRangedI16::Some(const_try_opt!(RangedI16::new(value))); + if value != 0 { + self.year_century_is_negative = value.is_negative(); + } else { + self.year_century_is_negative = is_negative; + } + Some(()) + } + + setters! { year_last_two set_year_last_two with_year_last_two u8; iso_year set_iso_year with_iso_year i32; iso_year_last_two set_iso_year_last_two with_iso_year_last_two u8; + } + + /// Set the `iso_year_century` component. + /// + /// If the value is zero, the sign of the century is taken from the second parameter. Otherwise + /// the sign is inferred from the value. + pub fn set_iso_year_century(&mut self, value: i16, is_negative: bool) -> Option<()> { + self.iso_year_century = OptionRangedI16::Some(const_try_opt!(RangedI16::new(value))); + if value != 0 { + self.iso_year_century_is_negative = value.is_negative(); + } else { + self.iso_year_century_is_negative = is_negative; + } + Some(()) + } + + setters! { month set_month with_month Month; sunday_week_number set_sunday_week_number with_sunday_week_number u8; monday_week_number set_monday_week_number with_monday_week_number u8; @@ -576,6 +668,20 @@ impl Parsed { Some(self) } + /// Set the `year_century` component and return `self`. + /// + /// If the value is zero, the sign of the century is taken from the second parameter. Otherwise + /// the sign is inferred from the value. + pub const fn with_year_century(mut self, value: i16, is_negative: bool) -> Option { + self.year_century = OptionRangedI16::Some(const_try_opt!(RangedI16::new(value))); + if value != 0 { + self.year_century_is_negative = value.is_negative(); + } else { + self.year_century_is_negative = is_negative; + } + Some(self) + } + /// Set the `year_last_two` component and return `self`. pub const fn with_year_last_two(mut self, value: u8) -> Option { self.year_last_two = OptionRangedU8::Some(const_try_opt!(RangedU8::new(value))); @@ -588,6 +694,20 @@ impl Parsed { Some(self) } + /// Set the `iso_year_century` component and return `self`. + /// + /// If the value is zero, the sign of the century is taken from the second parameter. Otherwise + /// the sign is inferred from the value. + pub const fn with_iso_year_century(mut self, value: i16, is_negative: bool) -> Option { + self.iso_year_century = OptionRangedI16::Some(const_try_opt!(RangedI16::new(value))); + if value != 0 { + self.iso_year_century_is_negative = value.is_negative(); + } else { + self.iso_year_century_is_negative = is_negative; + } + Some(self) + } + /// Set the `iso_year_last_two` component and return `self`. pub const fn with_iso_year_last_two(mut self, value: u8) -> Option { self.iso_year_last_two = OptionRangedU8::Some(const_try_opt!(RangedU8::new(value))); @@ -728,7 +848,7 @@ impl Parsed { impl TryFrom for Date { type Error = error::TryFromParsed; - fn try_from(parsed: Parsed) -> Result { + fn try_from(mut parsed: Parsed) -> Result { /// Match on the components that need to be present. macro_rules! match_ { (_ => $catch_all:expr $(,)?) => { @@ -758,8 +878,34 @@ impl TryFrom for Date { } } - // TODO Only the basics have been covered. There are many other valid values that are not - // currently constructed from the information known. + // If we do not have the year but we have *both* the century and the last two digits, we can + // construct the year. Likewise for the ISO year. + if let (None, Some(century), Some(is_negative), Some(last_two)) = ( + parsed.year(), + parsed.year_century(), + parsed.year_century_is_negative(), + parsed.year_last_two(), + ) { + let year = if is_negative { + 100 * century.extend::() - last_two.cast_signed().extend::() + } else { + 100 * century.extend::() + last_two.cast_signed().extend::() + }; + parsed.year = OptionRangedI32::from(RangedI32::new(year)); + } + if let (None, Some(century), Some(is_negative), Some(last_two)) = ( + parsed.iso_year(), + parsed.iso_year_century(), + parsed.iso_year_century_is_negative(), + parsed.iso_year_last_two(), + ) { + let iso_year = if is_negative { + 100 * century.extend::() - last_two.cast_signed().extend::() + } else { + 100 * century.extend::() + last_two.cast_signed().extend::() + }; + parsed.iso_year = OptionRangedI32::from(RangedI32::new(iso_year)); + } match_! { (year, ordinal) => Ok(Self::from_ordinal_date(year, ordinal.get())?),