From 72dbcb1ecce617643312dc6999f3bb2a538d9615 Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sat, 26 Sep 2026 07:20:43 +0300 Subject: [PATCH 1/7] perf(primitive): skip the whitespace combinator when no comment or sign follows --- src/items/primitive.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/items/primitive.rs b/src/items/primitive.rs index 42042a8..4a62e8d 100644 --- a/src/items/primitive.rs +++ b/src/items/primitive.rs @@ -32,7 +32,13 @@ fn multispace0<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E> where E: ParserError<&'a str>, { - take_while(0.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input) + let len = input + .bytes() + .take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\x0B' | b'\x0C' | b'\r')) + .count(); + let (ws, rest) = input.split_at(len); + *input = rest; + Ok(ws) } /// Same as [`multispace0`], but requiring at least one character @@ -50,6 +56,11 @@ pub(super) fn space<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> where E: ParserError<&'a str>, { + // Fast path: most calls see no comment or ignorable sign + multispace0.parse_next(input)?; + if !input.starts_with(['(', '-', '+']) { + return Ok(()); + } separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) } From ae312ed1ec3a037926215eeab124193171a3055e Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sat, 26 Sep 2026 07:20:44 +0300 Subject: [PATCH 2/7] perf(offset): map timezone names to offsets directly and defer the relative lookahead --- src/items/offset.rs | 187 +++++++++++++++++++++++--------------------- 1 file changed, 99 insertions(+), 88 deletions(-) diff --git a/src/items/offset.rs b/src/items/offset.rs index 5ccdc10..edf96fd 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -198,11 +198,21 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult { // "+8 years". GNU date parses them the second way, so we do the same here. // // Return early if the input can be parsed as a relative time. + // The lookahead is only needed when an offset would actually parse, so + // try the (cheap) offset first and check for a relative time afterwards. + let start = input.checkpoint(); + let result = alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input); + if matches!(result, Err(ErrMode::Backtrack(_))) { + return result; + } + + let end = input.checkpoint(); + input.reset(&start); if peek(relative::parse).parse_next(input).is_ok() { return Err(ErrMode::Backtrack(ContextError::new())); } - - alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input) + input.reset(&end); + result } /// Parse a timezone by name, with an optional numeric offset appended. @@ -215,15 +225,12 @@ fn timezone_name_offset(input: &mut &str) -> ModalResult { // second way, so we do the same here. // // Only process if the input cannot be parsed as a relative time. - if peek(relative::parse).parse_next(input).is_err() { - let start = input.checkpoint(); - if let Ok(other_tz) = timezone_offset.parse_next(input) { - let new_tz = tz.merge(other_tz); - - return Ok(new_tz); - }; - input.reset(&start); - } + // `timezone_offset` itself rejects input that parses as a relative time. + let start = input.checkpoint(); + if let Ok(other_tz) = timezone_offset.parse_next(input) { + return Ok(tz.merge(other_tz)); + }; + input.reset(&start); Ok(tz) } @@ -287,83 +294,87 @@ fn timezone_offset_colonless(input: &mut &str) -> ModalResult { /// https://www.timeanddate.com/time/zones/. GNU date only supports a subset of /// these. We support the same subset as GNU date. fn timezone_name_to_offset(input: &str) -> ModalResult { - let mut offset_str = match input { - "z" => Ok("+0"), - "y" => Ok("-12"), - "x" => Ok("-11"), - "wet" => Ok("+0"), - "west" => Ok("+1"), - "wat" => Ok("+1"), - "w" => Ok("-10"), - "v" => Ok("-9"), - "utc" => Ok("+0"), - "ut" => Ok("+0"), - "u" => Ok("-8"), - "t" => Ok("-7"), - "sst" => Ok("-11"), - "sgt" => Ok("+8"), - "sast" => Ok("+2"), - "s" => Ok("-6"), - "r" => Ok("-5"), - "q" => Ok("-4"), - "pst" => Ok("-8"), - "pdt" => Ok("-7"), - "p" => Ok("-3"), - "o" => Ok("-2"), - "nzst" => Ok("+12"), - "nzdt" => Ok("+13"), - "nst" => Ok("-3:30"), - "ndt" => Ok("-2:30"), - "n" => Ok("-1"), - "mst" => Ok("-7"), - "msk" => Ok("+3"), - "msd" => Ok("+4"), - "mez" => Ok("+1"), - "mesz" => Ok("+2"), - "mest" => Ok("+2"), - "mdt" => Ok("-6"), - "m" => Ok("+12"), - "l" => Ok("+11"), - "kst" => Ok("+9"), - "k" => Ok("+10"), - "jst" => Ok("+9"), - "ist" => Ok("+5:30"), - "i" => Ok("+9"), - "hst" => Ok("-10"), - "h" => Ok("+8"), - "gst" => Ok("+4"), - "gmt" => Ok("+0"), - "g" => Ok("+7"), - "f" => Ok("+6"), - "est" => Ok("-5"), - "eet" => Ok("+2"), - "eest" => Ok("+3"), - "edt" => Ok("-4"), - "eat" => Ok("+3"), - "e" => Ok("+5"), - "d" => Ok("+4"), - "cst" => Ok("-6"), - "clt" => Ok("-4"), - "clst" => Ok("-3"), - "cet" => Ok("+1"), - "cest" => Ok("+2"), - "cdt" => Ok("-5"), - "cat" => Ok("+2"), - "c" => Ok("+3"), - "bst" => Ok("+6"), - "brt" => Ok("-3"), - "brst" => Ok("-2"), - "b" => Ok("+2"), - "ast" => Ok("-3"), - "art" => Ok("-3"), - "akst" => Ok("-9"), - "akdt" => Ok("-8"), - "adt" => Ok("+4"), - "a" => Ok("+1"), - _ => Err(ErrMode::Backtrack(ContextError::new())), - }?; - - timezone_offset(&mut offset_str) + let (negative, hours, minutes) = match input { + "z" => (false, 0, 0), + "y" => (true, 12, 0), + "x" => (true, 11, 0), + "wet" => (false, 0, 0), + "west" => (false, 1, 0), + "wat" => (false, 1, 0), + "w" => (true, 10, 0), + "v" => (true, 9, 0), + "utc" => (false, 0, 0), + "ut" => (false, 0, 0), + "u" => (true, 8, 0), + "t" => (true, 7, 0), + "sst" => (true, 11, 0), + "sgt" => (false, 8, 0), + "sast" => (false, 2, 0), + "s" => (true, 6, 0), + "r" => (true, 5, 0), + "q" => (true, 4, 0), + "pst" => (true, 8, 0), + "pdt" => (true, 7, 0), + "p" => (true, 3, 0), + "o" => (true, 2, 0), + "nzst" => (false, 12, 0), + "nzdt" => (false, 13, 0), + "nst" => (true, 3, 30), + "ndt" => (true, 2, 30), + "n" => (true, 1, 0), + "mst" => (true, 7, 0), + "msk" => (false, 3, 0), + "msd" => (false, 4, 0), + "mez" => (false, 1, 0), + "mesz" => (false, 2, 0), + "mest" => (false, 2, 0), + "mdt" => (true, 6, 0), + "m" => (false, 12, 0), + "l" => (false, 11, 0), + "kst" => (false, 9, 0), + "k" => (false, 10, 0), + "jst" => (false, 9, 0), + "ist" => (false, 5, 30), + "i" => (false, 9, 0), + "hst" => (true, 10, 0), + "h" => (false, 8, 0), + "gst" => (false, 4, 0), + "gmt" => (false, 0, 0), + "g" => (false, 7, 0), + "f" => (false, 6, 0), + "est" => (true, 5, 0), + "eet" => (false, 2, 0), + "eest" => (false, 3, 0), + "edt" => (true, 4, 0), + "eat" => (false, 3, 0), + "e" => (false, 5, 0), + "d" => (false, 4, 0), + "cst" => (true, 6, 0), + "clt" => (true, 4, 0), + "clst" => (true, 3, 0), + "cet" => (false, 1, 0), + "cest" => (false, 2, 0), + "cdt" => (true, 5, 0), + "cat" => (false, 2, 0), + "c" => (false, 3, 0), + "bst" => (false, 6, 0), + "brt" => (true, 3, 0), + "brst" => (true, 2, 0), + "b" => (false, 2, 0), + "ast" => (true, 3, 0), + "art" => (true, 3, 0), + "akst" => (true, 9, 0), + "akdt" => (true, 8, 0), + "adt" => (false, 4, 0), + "a" => (false, 1, 0), + _ => return Err(ErrMode::Backtrack(ContextError::new())), + }; + + Ok(Offset { + negative, + hours, + minutes, + }) } #[cfg(test)] From 5dd9b11ddeb66bc4250811dbd14ae3fc3f4483f9 Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sat, 26 Sep 2026 07:20:44 +0300 Subject: [PATCH 3/7] perf(items): parse an ISO date once and lowercase the input only when needed --- src/items/combined.rs | 7 +++++++ src/items/mod.rs | 25 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/items/combined.rs b/src/items/combined.rs index 9729c15..d294a23 100644 --- a/src/items/combined.rs +++ b/src/items/combined.rs @@ -37,8 +37,15 @@ fn remaining_starts_with_meridiem(input: &str) -> bool { || trimmed.starts_with("p.m.") } +#[cfg(test)] pub(crate) fn parse(input: &mut &str) -> ModalResult { let date = trace("iso_date", alt((date::iso1, date::iso2))).parse_next(input)?; + parse_time_after_date(input, date) +} + +/// Parse the separator and time of a combined item whose ISO date has already +/// been parsed. +pub(crate) fn parse_time_after_date(input: &mut &str, date: date::Date) -> ModalResult { // Note: the `T` is lowercased by the main parse function alt((s('t').void(), (' ', space).void())).parse_next(input)?; diff --git a/src/items/mod.rs b/src/items/mod.rs index 928ca42..c7196b9 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -236,8 +236,12 @@ fn parse_items(input: &mut &str) -> ModalResult { let tz = timezone::parse(input).map(Item::TimeZone); // Convert input to lowercase for case-insensitive parsing. - let lower = input.to_ascii_lowercase(); - let input = &mut lower.as_str(); + let lower: std::borrow::Cow = if input.bytes().any(|b| b.is_ascii_uppercase()) { + input.to_ascii_lowercase().into() + } else { + (*input).into() + }; + let input = &mut lower.as_ref(); let (mut items, _): (Vec, _) = trace( "parse_items", @@ -255,10 +259,25 @@ fn parse_items(input: &mut &str) -> ModalResult { /// Parse an item. fn parse_item(input: &mut &str) -> ModalResult { + // An ISO date is either the start of a combined date and time item or a + // date item on its own. Parse it once instead of once per alternative. + let start = input.checkpoint(); + if let Ok(date) = alt((date::iso1, date::iso2)).parse_next(input) { + let after_date = input.checkpoint(); + match combined::parse_time_after_date(input, date.clone()) { + Ok(dt) => return Ok(Item::DateTime(dt)), + Err(ErrMode::Backtrack(_)) => { + input.reset(&after_date); + return Ok(Item::Date(date)); + } + Err(e) => return Err(e), + } + } + input.reset(&start); + trace( "parse_item", alt(( - combined::parse.map(Item::DateTime), date::parse.map(Item::Date), time::parse.map(Item::Time), relative::parse.map(Item::Relative), From 1d139d8d14f033ae05c58f0cd58ebb2082b42e7c Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sun, 27 Sep 2026 05:23:51 +0300 Subject: [PATCH 4/7] refactor(items): address review cleanups --- src/items/builder.rs | 4 ++-- src/items/combined.rs | 17 +++++++++-------- src/items/date.rs | 14 +++++++++++--- src/items/mod.rs | 6 ++++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index dc74cfe..1ead051 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -581,11 +581,11 @@ mod tests { fn date() -> date::Date { let mut input = "2023-06-15"; - date::parse(&mut input).unwrap() + date::iso1(&mut input).unwrap() } fn date_large(mut input: &str) -> date::Date { - date::parse(&mut input).unwrap() + date::iso1(&mut input).unwrap() } fn time() -> time::Time { diff --git a/src/items/combined.rs b/src/items/combined.rs index d294a23..8852d6c 100644 --- a/src/items/combined.rs +++ b/src/items/combined.rs @@ -37,12 +37,6 @@ fn remaining_starts_with_meridiem(input: &str) -> bool { || trimmed.starts_with("p.m.") } -#[cfg(test)] -pub(crate) fn parse(input: &mut &str) -> ModalResult { - let date = trace("iso_date", alt((date::iso1, date::iso2))).parse_next(input)?; - parse_time_after_date(input, date) -} - /// Parse the separator and time of a combined item whose ISO date has already /// been parsed. pub(crate) fn parse_time_after_date(input: &mut &str, date: date::Date) -> ModalResult { @@ -66,8 +60,15 @@ pub(crate) fn parse_time_after_date(input: &mut &str, date: date::Date) -> Modal #[cfg(test)] mod tests { - use super::{parse, DateTime}; - use crate::items::{date::Date, time::Time}; + use winnow::{combinator::alt, ModalResult, Parser}; + + use super::{parse_time_after_date, DateTime}; + use crate::items::{date, date::Date, time::Time}; + + fn parse(input: &mut &str) -> ModalResult { + let date = alt((date::iso1, date::iso2)).parse_next(input)?; + parse_time_after_date(input, date) + } #[test] fn some_date() { diff --git a/src/items/date.rs b/src/items/date.rs index 1f1389f..2ff8333 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -127,8 +127,10 @@ impl TryFrom for jiff::civil::Date { } } -pub(super) fn parse(input: &mut &str) -> ModalResult { - alt((iso1, iso2, us, literal1, literal2, literal3)).parse_next(input) +/// Parse a date that is not in ISO format. ISO dates are parsed by [`iso1`] +/// and [`iso2`], which the caller tries first. +pub(super) fn parse_non_iso(input: &mut &str) -> ModalResult { + alt((us, literal1, literal2, literal3)).parse_next(input) } /// Parse `[year]-[month]-[day]` @@ -299,7 +301,13 @@ fn day_from_str(s: &str) -> ModalResult { #[cfg(test)] mod tests { - use super::{parse, Date}; + use winnow::{combinator::alt, ModalResult, Parser}; + + use super::Date; + + fn parse(input: &mut &str) -> ModalResult { + alt((super::iso1, super::iso2, super::parse_non_iso)).parse_next(input) + } // Test cases from the GNU docs: // diff --git a/src/items/mod.rs b/src/items/mod.rs index c7196b9..cb4f12b 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -48,6 +48,8 @@ mod primitive; pub(crate) mod error; +use std::borrow::Cow; + use crate::ParsedDateTime; use jiff::Zoned; use primitive::space; @@ -236,7 +238,7 @@ fn parse_items(input: &mut &str) -> ModalResult { let tz = timezone::parse(input).map(Item::TimeZone); // Convert input to lowercase for case-insensitive parsing. - let lower: std::borrow::Cow = if input.bytes().any(|b| b.is_ascii_uppercase()) { + let lower: Cow = if input.bytes().any(|b| b.is_ascii_uppercase()) { input.to_ascii_lowercase().into() } else { (*input).into() @@ -278,7 +280,7 @@ fn parse_item(input: &mut &str) -> ModalResult { trace( "parse_item", alt(( - date::parse.map(Item::Date), + date::parse_non_iso.map(Item::Date), time::parse.map(Item::Time), relative::parse.map(Item::Relative), weekday::parse.map(Item::Weekday), From c4aee35a6cbcef125efb22fa6fa016eb81915292 Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sun, 27 Sep 2026 05:24:04 +0300 Subject: [PATCH 5/7] refactor(offset): shorten the relative lookahead comments --- src/items/offset.rs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/items/offset.rs b/src/items/offset.rs index edf96fd..562b304 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -193,13 +193,8 @@ pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> { /// Parse a timezone starting with `+` or `-`. pub(super) fn timezone_offset(input: &mut &str) -> ModalResult { - // Strings like "+8 years" are ambiguous, they can either be parsed as a - // timezone offset "+8" and a relative time "years", or just a relative time - // "+8 years". GNU date parses them the second way, so we do the same here. - // - // Return early if the input can be parsed as a relative time. - // The lookahead is only needed when an offset would actually parse, so - // try the (cheap) offset first and check for a relative time afterwards. + // Like GNU, read "+8 years" as a relative item, not as "+8" and "years". + // The costlier relative check only runs once an offset has parsed. let start = input.checkpoint(); let result = alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input); if matches!(result, Err(ErrMode::Backtrack(_))) { @@ -220,12 +215,8 @@ fn timezone_name_offset(input: &mut &str) -> ModalResult { let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?; let tz = timezone_name_to_offset(nextword)?; - // Strings like "UTC +8 years" are ambiguous, they can either be parsed as - // "UTC+8" and "years", or "UTC" and "+8 years". GNU date parses them the - // second way, so we do the same here. - // - // Only process if the input cannot be parsed as a relative time. - // `timezone_offset` itself rejects input that parses as a relative time. + // Like GNU, read "UTC +8 years" as "UTC" and "+8 years", not as "UTC+8" + // and "years". `timezone_offset` rejects input that parses as relative. let start = input.checkpoint(); if let Ok(other_tz) = timezone_offset.parse_next(input) { return Ok(tz.merge(other_tz)); From b118462da28a6f66730a2d7cd1c850a8eff11c0c Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sun, 27 Sep 2026 05:25:01 +0300 Subject: [PATCH 6/7] test(offset): cover offsets followed by a relative item --- src/items/mod.rs | 15 +++++++++++++++ src/items/offset.rs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/items/mod.rs b/src/items/mod.rs index cb4f12b..c5c2cce 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -764,6 +764,21 @@ mod tests { assert_eq!(result.second(), 0); } + #[test] + fn offset_followed_by_relative() { + let base = "2000-01-01 12:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + + // Both read "+8 years" as a relative item, not as an offset of +8. + for input in ["UTC +8 years", "+8 years"] { + let result = parse_at_date(base.clone(), input).unwrap(); + assert_eq!(result.to_string(), "2008-01-01 12:00:00+00:00", "{input}"); + } + } + #[test] fn pure() { let now = Zoned::now().with_time_zone(TimeZone::UTC); diff --git a/src/items/offset.rs b/src/items/offset.rs index 562b304..4268919 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -505,6 +505,24 @@ mod tests { } } + #[test] + fn offset_followed_by_relative() { + // "+8 years" is a relative item, so it is not an offset. + let mut s = "+8 years"; + assert!(timezone_offset(&mut s).is_err()); + assert_eq!(s, "+8 years"); + + // "utc +8 years" is "utc" followed by the relative item "+8 years". + let mut s = "utc +8 years"; + assert_eq!(timezone_name_offset(&mut s).unwrap(), off(false, 0, 0)); + assert_eq!(s, " +8 years"); + + // Without a relative item, the offset is still merged. + let mut s = "utc +8"; + assert_eq!(timezone_name_offset(&mut s).unwrap(), off(false, 8, 0)); + assert!(s.is_empty()); + } + #[test] fn total_seconds() { assert_eq!(off(false, 0, 0).total_seconds(), 0); From 37eab195d96a5e5af4b73134c70d5ca8276a5668 Mon Sep 17 00:00:00 2001 From: Anas Awadalla Date: Sun, 27 Sep 2026 05:26:21 +0300 Subject: [PATCH 7/7] test(primitive): cover comments and ignored signs between items --- src/items/mod.rs | 24 ++++++++++++++++++++++++ src/items/primitive.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/items/mod.rs b/src/items/mod.rs index c5c2cce..58c549b 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -779,6 +779,30 @@ mod tests { } } + #[test] + fn comments_and_ignored_signs() { + let base = "2000-06-15 12:34:56" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + + for (input, expected) in [ + ("1997-01-01 (foo) 10:00", "1997-01-01 10:00:00+00:00"), + ("1997-01-01 (a (b)) 10:00", "1997-01-01 10:00:00+00:00"), + ("(foo) 1997-01-01", "1997-01-01 00:00:00+00:00"), + ("+ (c) 1997-01-01", "1997-01-01 00:00:00+00:00"), + ("- (c) 1997-01-01", "1997-01-01 00:00:00+00:00"), + ("1997-01-01 (x) + 1 day", "1997-01-02 00:00:00+00:00"), + ] { + let result = parse_at_date(base.clone(), input).unwrap(); + assert_eq!(result.to_string(), expected, "{input}"); + } + + // Like GNU, a hyphen followed by a digit is a sign, not ignored. + assert!(parse_at_date(base.clone(), "- 1997-01-01").is_err()); + } + #[test] fn pure() { let now = Zoned::now().with_time_zone(TimeZone::UTC); diff --git a/src/items/primitive.rs b/src/items/primitive.rs index 4a62e8d..f8e0709 100644 --- a/src/items/primitive.rs +++ b/src/items/primitive.rs @@ -230,4 +230,27 @@ mod tests { ); } } + + #[test] + fn space_skips_comments_and_ignored_signs() { + for (input, rest) in [ + ("", ""), + ("abc", "abc"), + (" \t abc", "abc"), + ("(foo) abc", "abc"), + (" (a (b)) abc", "abc"), + ("- abc", "abc"), + ("+ (c) abc", "abc"), + ("- (c) 12", "12"), + // A sign followed by a digit is not ignored. + ("- 12", "- 12"), + (" +12", "+12"), + // An unbalanced comment is not skipped. + ("(foo abc", "(foo abc"), + ] { + let mut s = input; + space::(&mut s).unwrap(); + assert_eq!(s, rest, "{input:?}"); + } + } }