From 62a9b02fa2f431ac98833130ec3efbbee8226ac9 Mon Sep 17 00:00:00 2001 From: Socialpranker <273312799+Socialpranker@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:27:05 +0200 Subject: [PATCH] fix(offset): treat a numeric offset after a time as a zone correction GNU reads '12:00 +3 hours' as the zone +03:00 plus a bare 'hours', not as a relative item. The correction after a colon time no longer yields to a following relative unit; a fractional number still does, and the bare-hour and named-zone forms are unchanged. --- src/items/mod.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/items/offset.rs | 22 +++++++++++++++++++++- src/items/time.rs | 4 ++-- tests/time.rs | 21 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/items/mod.rs b/src/items/mod.rs index 58c549b..dc0fb7a 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -876,4 +876,48 @@ mod tests { // duplicate check. GNU rejects it too. assert!(parse(&mut "j +05:00").is_err()); } + + /// A numeric offset after a time of day is a time zone correction, even + /// when a relative item follows it. GNU `date` reads + /// `2026-08-27 12:00 +3 hours` as the zone `+03:00` plus a bare `hours` + /// (which means one hour), i.e. 10:00 UTC. + #[test] + fn numeric_offset_before_relative_item() { + for (expected, input) in [ + ("2026-08-27 10:00:00", "2026-08-27 12:00 +3 hours"), + ("2026-08-27 10:00:00", "2026-08-27 12:00 +3 hour"), + ("2026-08-27 10:00:00", "2026-08-27 12:00 +3hours"), + ("2026-08-27 10:00:00", "2026-08-27 12:00 + 3 hours"), + ("2026-08-27 10:00:00", "2026-08-27 12:00 +03 hours"), + ("2026-08-27 10:00:00", "2026-08-27 12:00 +0300 hours"), + ("2026-08-27 10:00:00", "2026-08-27 12:00:00 +3 hours"), + ("2026-08-27 10:05:00", "2026-08-27 12:00 +3 hours 5 minutes"), + ("2026-08-27 08:00:00", "2026-08-27 12:00 +3 hours ago"), + ("2026-08-28 09:00:00", "2026-08-27 12:00 +3 days"), + ("2026-08-27 09:01:00", "2026-08-27 12:00 +3 minutes"), + ("2026-08-27 16:00:00", "2026-08-27 12:00 -3 hours"), + ("2026-08-27 00:00:00", "2026-08-27 12:00 +13 hours"), + // A named zone already fills the zone slot, so the numeric value + // stays a relative item. + ("2026-08-27 15:00:00", "2026-08-27 12:00 utc +3 hours"), + // Without a time of day there is no zone slot to fill either. + ("2026-08-27 03:00:00", "2026-08-27 +3 hours"), + // A fractional number is never a zone correction. + ("2026-08-27 12:00:01", "2026-08-27 12:00 +1.5 seconds"), + ] { + let parsed = parse(&mut &*input) + .map(at_utc) + .expect("parsing failed during tests") + .with_time_zone(TimeZone::UTC); + assert_eq!( + expected, + parsed.strftime("%Y-%m-%d %H:%M:%S").to_string(), + "{input}" + ); + } + + // `+30` is not a valid zone correction, and GNU rejects the whole + // input rather than falling back to a relative item. + assert!(parse(&mut "2026-08-27 12:00 +30 hours").is_err()); + } } diff --git a/src/items/offset.rs b/src/items/offset.rs index e8afed5..68d6017 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -31,7 +31,7 @@ use winnow::{ combinator::{alt, peek}, error::{ContextError, ErrMode}, stream::{AsChar, Stream}, - token::take_while, + token::{one_of, take_while}, ModalResult, Parser, }; @@ -191,6 +191,26 @@ pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> { .parse_next(input) } +/// Parse the zone correction that follows a time of day. +/// +/// Unlike [`timezone_offset`], a relative unit after the number does not +/// disqualify it: GNU reads `12:00 +3 hours` as the zone `+03:00` plus a bare +/// `hours`. A fractional number is never a correction (`12:00 +1.5 seconds`). +pub(super) fn time_zone_correction(input: &mut &str) -> ModalResult { + let has_fraction = peek(( + plus_or_minus::>, + s(dec_uint_str), + '.', + one_of(AsChar::is_dec_digit), + )) + .parse_next(input) + .is_ok(); + if has_fraction { + return Err(ErrMode::Backtrack(ContextError::new())); + } + alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input) +} + /// Parse a timezone starting with `+` or `-`. pub(super) fn timezone_offset(input: &mut &str) -> ModalResult { // Like GNU, read "+8 years" as a relative item, not as "+8" and "years". diff --git a/src/items/time.rs b/src/items/time.rs index 777ca5e..893ef5e 100644 --- a/src/items/time.rs +++ b/src/items/time.rs @@ -45,7 +45,7 @@ use winnow::{ use super::{ epoch::sec_and_nsec, - offset::{timezone_offset, Offset}, + offset::{time_zone_correction, timezone_offset, Offset}, primitive::{colon, ctx_err, dec_uint, s}, }; @@ -99,7 +99,7 @@ pub(super) fn iso(input: &mut &str) -> ModalResult