Skip to content

Commit

Permalink
Add ISO 8601 parser for duration format with designators
Browse files Browse the repository at this point in the history
  • Loading branch information
pitdicker committed Sep 22, 2023
1 parent 373913f commit 7e8e29f
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 3 deletions.
14 changes: 14 additions & 0 deletions src/calendar_duration.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use core::fmt;
use core::num::NonZeroU32;
use core::str;
use core::time::Duration;

use crate::format::{parse_iso8601_duration, ParseError, TOO_LONG};
use crate::{expect, try_opt};

/// ISO 8601 duration type.
Expand Down Expand Up @@ -117,6 +119,18 @@ impl fmt::Display for CalendarDuration {
}
}

impl str::FromStr for CalendarDuration {
type Err = ParseError;

fn from_str(s: &str) -> Result<CalendarDuration, ParseError> {
let (s, duration) = parse_iso8601_duration(s)?;
if !s.is_empty() {
return Err(TOO_LONG);
}
Ok(duration)
}

Check warning on line 131 in src/calendar_duration.rs

View check run for this annotation

Codecov / codecov/patch

src/calendar_duration.rs#L125-L131

Added lines #L125 - L131 were not covered by tests
}

impl CalendarDuration {
/// Create a new duration initialized to `0`.
///
Expand Down
1 change: 1 addition & 0 deletions src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub use locales::Locale;
pub(crate) use locales::Locale;
pub(crate) use parse::parse_rfc3339;
pub use parse::{parse, parse_and_remainder};
pub(crate) use parse_iso8601::parse_iso8601_duration;
pub use parsed::Parsed;
pub use strftime::StrftimeItems;

Expand Down
290 changes: 287 additions & 3 deletions src/format/parse_iso8601.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,164 @@
use super::scan;
use super::{ParseResult, INVALID, OUT_OF_RANGE};
use crate::CalendarDuration;

/// Parser for the ISO 8601 duration format with designators.
///
/// Supported formats:
/// - `Pnn̲Ynn̲Mnn̲DTnn̲Hnn̲Mnn̲S`
/// - `Pnn̲W`
///
/// Any number-designator pair may be missing when zero, as long as there is at least one pair.
/// The last pair may contain a decimal fraction instead of an integer.
///
/// - Fractional years will be expressed in months.
/// - Fractional weeks will be expressed in days.
/// - Fractional hours, minutes or seconds will be expressed in minutes, seconds and nanoseconds.
pub(crate) fn parse_iso8601_duration(mut s: &str) -> ParseResult<(&str, CalendarDuration)> {
macro_rules! consume {
($e:expr) => {{
$e.map(|(s_, v)| {
s = s_;
v
})
}};
}

s = scan::char(s, b'P')?;
let mut duration = CalendarDuration::new();

let mut next = consume!(Decimal::parse(s)).ok();
if let Some(val) = next {
if s.as_bytes().first() == Some(&b'W') {
s = &s[1..];
// Nothing is allowed after a week value
return Ok((s, duration.with_days(val.mul(7)?)));
}
if s.as_bytes().first() == Some(&b'Y') {
s = &s[1..];
duration = duration.with_months(val.mul(12)?);
if val.fraction.is_some() {
return Ok((s, duration));
}
next = consume!(Decimal::parse(s)).ok();
}
}

if let Some(val) = next {
if s.as_bytes().first() == Some(&b'M') {
s = &s[1..];
let months = duration.months().checked_add(val.integer()?).ok_or(OUT_OF_RANGE)?;
duration = duration.with_months(months);
next = consume!(Decimal::parse(s)).ok();
}
}

if let Some(val) = next {
if s.as_bytes().first() == Some(&b'D') {
s = &s[1..];
duration = duration.with_days(val.integer()?);
next = None;
}
}

if next.is_some() {
// We have numbers without a matching designator.
return Err(INVALID);
}

if s.as_bytes().first() == Some(&b'T') {
duration = consume!(parse_iso8601_duration_time(s, duration))?
}
Ok((s, duration))
}

/// Parser for the time part of the ISO 8601 duration format with designators.
pub(crate) fn parse_iso8601_duration_time(
mut s: &str,
duration: CalendarDuration,
) -> ParseResult<(&str, CalendarDuration)> {
macro_rules! consume_or_return {
($e:expr, $return:expr) => {{
match $e {
Ok((s_, next)) => {
s = s_;
next
}
Err(_) => return $return,
}
}};
}
fn set_hms_nano(
duration: CalendarDuration,
hours: u32,
minutes: u32,
seconds: u32,
nanoseconds: u32,
) -> ParseResult<CalendarDuration> {
let duration = match (hours, minutes) {
(0, 0) => duration.with_seconds(seconds),
_ => duration.with_hms(hours, minutes, seconds).ok_or(OUT_OF_RANGE)?,
};
Ok(duration.with_nanos(nanoseconds).unwrap())
}

s = scan::char(s, b'T')?;
let mut hours = 0;
let mut minutes = 0;
let mut incomplete = true; // at least one component is required

let (s_, mut next) = Decimal::parse(s)?;
s = s_;
if s.as_bytes().first() == Some(&b'H') {
s = &s[1..];
incomplete = false;
match next.integer() {
Ok(h) => hours = h,
_ => {
let (secs, nanos) = next.mul_with_nanos(3600)?;
let mins = secs / 60;
let secs = (secs % 60) as u32;
let minutes = u32::try_from(mins).map_err(|_| OUT_OF_RANGE)?;
return Ok((s, set_hms_nano(duration, 0, minutes, secs, nanos)?));
}
}
next = consume_or_return!(
Decimal::parse(s),
Ok((s, set_hms_nano(duration, hours, minutes, 0, 0)?))
);
}

if s.as_bytes().first() == Some(&b'M') {
s = &s[1..];
incomplete = false;
match next.integer() {
Ok(m) => minutes = m,
_ => {
let (secs, nanos) = next.mul_with_nanos(60)?;
let mins = secs / 60;
let secs = (secs % 60) as u32;
minutes = u32::try_from(mins).map_err(|_| OUT_OF_RANGE)?;
return Ok((s, set_hms_nano(duration, hours, minutes, secs, nanos)?));
}
}
next = consume_or_return!(
Decimal::parse(s),
Ok((s, set_hms_nano(duration, hours, minutes, 0, 0)?))
);
}

if s.as_bytes().first() == Some(&b'S') {
s = &s[1..];
let (secs, nanos) = next.mul_with_nanos(1)?;
let secs = u32::try_from(secs).map_err(|_| OUT_OF_RANGE)?;
return Ok((s, set_hms_nano(duration, hours, minutes, secs, nanos)?));
}

if incomplete {
return Err(INVALID);
}
Ok((s, set_hms_nano(duration, hours, minutes, 0, 0)?))

Check warning on line 160 in src/format/parse_iso8601.rs

View check run for this annotation

Codecov / codecov/patch

src/format/parse_iso8601.rs#L159-L160

Added lines #L159 - L160 were not covered by tests
}

/// Helper type for parsing decimals (as in an ISO 8601 duration).
#[derive(Copy, Clone)]

Check warning on line 164 in src/format/parse_iso8601.rs

View check run for this annotation

Codecov / codecov/patch

src/format/parse_iso8601.rs#L164

Added line #L164 was not covered by tests
Expand Down Expand Up @@ -96,7 +255,7 @@ impl Fraction {
let huge = self.0 * unit + div / 2;
let whole = huge / POW10[15];
let fraction_as_nanos = (huge % POW10[15]) / div;
dbg!(whole as i64, fraction_as_nanos as i64)
(whole as i64, fraction_as_nanos as i64)
}
}

Expand All @@ -121,8 +280,9 @@ const POW10: [u64; 16] = [

#[cfg(test)]
mod tests {
use super::Fraction;
use crate::format::INVALID;
use super::{parse_iso8601_duration, parse_iso8601_duration_time, Fraction};
use crate::format::{INVALID, OUT_OF_RANGE, TOO_SHORT};
use crate::CalendarDuration;

#[test]
fn test_parse_fraction() {
Expand All @@ -138,4 +298,128 @@ mod tests {
let (_, fraction) = Fraction::parse(",5").unwrap();
assert_eq!(fraction.mul_with_nanos(1), (0, 500_000_000));
}

#[test]
fn test_parse_duration_time() {
let parse = parse_iso8601_duration_time;
let d = CalendarDuration::new();

assert_eq!(parse("T12H", d), Ok(("", d.with_hms(12, 0, 0).unwrap())));
assert_eq!(parse("T12.25H", d), Ok(("", d.with_hms(12, 15, 0).unwrap())));
assert_eq!(parse("T12,25H", d), Ok(("", d.with_hms(12, 15, 0).unwrap())));
assert_eq!(parse("T34M", d), Ok(("", d.with_hms(0, 34, 0).unwrap())));
assert_eq!(parse("T34.25M", d), Ok(("", d.with_hms(0, 34, 15).unwrap())));
assert_eq!(parse("T56S", d), Ok(("", d.with_seconds(56))));
assert_eq!(parse("T0.789S", d), Ok(("", d.with_millis(789).unwrap())));
assert_eq!(parse("T0,789S", d), Ok(("", d.with_millis(789).unwrap())));
assert_eq!(parse("T12H34M", d), Ok(("", d.with_hms(12, 34, 0).unwrap())));
assert_eq!(parse("T12H34M60S", d), Ok(("", d.with_hms(12, 34, 60).unwrap())));
assert_eq!(
parse("T12H34M56.789S", d),
Ok(("", d.with_hms(12, 34, 56).unwrap().with_millis(789).unwrap()))
);
assert_eq!(parse("T12H56S", d), Ok(("", d.with_hms(12, 0, 56).unwrap())));
assert_eq!(parse("T34M56S", d), Ok(("", d.with_hms(0, 34, 56).unwrap())));

// Data after a fraction is ignored
assert_eq!(parse("T12.5H16M", d), Ok(("16M", d.with_hms(12, 30, 0).unwrap())));
assert_eq!(parse("T12H16.5M30S", d), Ok(("30S", d.with_hms(12, 16, 30).unwrap())));

// Zero values
assert_eq!(parse("T0H", d), Ok(("", d)));
assert_eq!(parse("T0M", d), Ok(("", d)));
assert_eq!(parse("T0S", d), Ok(("", d)));
assert_eq!(parse("T0,0S", d), Ok(("", d)));

// Empty or invalid values
assert_eq!(parse("T", d), Err(TOO_SHORT));
assert_eq!(parse("TH", d), Err(INVALID));
assert_eq!(parse("TM", d), Err(INVALID));
assert_eq!(parse("TS", d), Err(INVALID));
assert_eq!(parse("T.5S", d), Err(INVALID));
assert_eq!(parse("T,5S", d), Err(INVALID));

// Date components
assert_eq!(parse("T5W", d), Err(INVALID));
assert_eq!(parse("T5Y", d), Err(INVALID));
assert_eq!(parse("T5D", d), Err(INVALID));

// Max values
assert_eq!(parse("T1118481H", d), Ok(("", d.with_hms(1118481, 0, 0).unwrap())));
assert_eq!(parse("T1118482H", d), Err(OUT_OF_RANGE));
assert_eq!(parse("T1118481.05H", d), Ok(("", d.with_hms(1118481, 3, 0).unwrap())));
assert_eq!(parse("T1118481.5H", d), Err(OUT_OF_RANGE));
assert_eq!(parse("T67108863M", d), Ok(("", d.with_hms(0, u32::MAX >> 6, 0).unwrap())));
assert_eq!(parse("T67108864M", d), Err(OUT_OF_RANGE));
assert_eq!(parse("T67108863.25M", d), Ok(("", d.with_hms(0, u32::MAX >> 6, 15).unwrap())));
assert_eq!(parse("T4294967295S", d), Ok(("", d.with_seconds(u32::MAX))));
assert_eq!(parse("T4294967296S", d), Err(OUT_OF_RANGE));
assert_eq!(
parse("T4294967295.25S", d),
Ok(("", d.with_seconds(u32::MAX).with_millis(250).unwrap()))
);
assert_eq!(
parse("T4294967295.999999999S", d),
Ok(("", d.with_seconds(u32::MAX).with_nanos(999_999_999).unwrap()))
);
assert_eq!(parse("T4294967295.9999999995S", d), Err(OUT_OF_RANGE));
assert_eq!(parse("T12H34M61S", d), Err(OUT_OF_RANGE));
}

#[test]
fn test_parse_duration() {
let d = CalendarDuration::new();
assert_eq!(
parse_iso8601_duration("P12Y"),
Ok(("", d.with_years_and_months(12, 0).unwrap()))
);
assert_eq!(parse_iso8601_duration("P34M"), Ok(("", d.with_months(34))));
assert_eq!(parse_iso8601_duration("P56D"), Ok(("", d.with_days(56))));
assert_eq!(parse_iso8601_duration("P78W"), Ok(("", d.with_weeks_and_days(78, 0).unwrap())));

// Fractional date values
assert_eq!(
parse_iso8601_duration("P1.25Y"),
Ok(("", d.with_years_and_months(1, 3).unwrap()))
);
assert_eq!(
parse_iso8601_duration("P1.99Y"),
Ok(("", d.with_years_and_months(2, 0).unwrap()))
);
assert_eq!(parse_iso8601_duration("P1.4W"), Ok(("", d.with_days(10))));
assert_eq!(parse_iso8601_duration("P1.95W"), Ok(("", d.with_days(14))));
assert_eq!(parse_iso8601_duration("P1.5M"), Err(INVALID));
assert_eq!(parse_iso8601_duration("P1.5D"), Err(INVALID));

// Data after a fraction is ignored
assert_eq!(
parse_iso8601_duration("P1.25Y5D"),
Ok(("5D", d.with_years_and_months(1, 3).unwrap()))
);
assert_eq!(
parse_iso8601_duration("P1.25YT3H"),
Ok(("T3H", d.with_years_and_months(1, 3).unwrap()))
);

// Zero values
assert_eq!(parse_iso8601_duration("P0Y"), Ok(("", d)));
assert_eq!(parse_iso8601_duration("P0M"), Ok(("", d)));
assert_eq!(parse_iso8601_duration("P0W"), Ok(("", d)));
assert_eq!(parse_iso8601_duration("P0D"), Ok(("", d)));
assert_eq!(parse_iso8601_duration("PT0M"), Ok(("", d)));
assert_eq!(parse_iso8601_duration("PT0S"), Ok(("", d)));

// Invalid designator at a position where another designator can be expected.
assert_eq!(parse_iso8601_duration("P12Y12Y"), Err(INVALID));
assert_eq!(parse_iso8601_duration("P12M12M"), Err(INVALID));
assert_eq!(parse_iso8601_duration("P12M12Y"), Err(INVALID));

// Trailing data
assert_eq!(
parse_iso8601_duration("P12W34D"),
Ok(("34D", d.with_weeks_and_days(12, 0).unwrap()))
);
assert_eq!(parse_iso8601_duration("P12D12D"), Ok(("12D", d.with_days(12))));
assert_eq!(parse_iso8601_duration("P12D12Y"), Ok(("12Y", d.with_days(12))));
}
}

0 comments on commit 7e8e29f

Please sign in to comment.