diff --git a/src/items/timezone.rs b/src/items/timezone.rs index 0414ee8..8057b40 100644 --- a/src/items/timezone.rs +++ b/src/items/timezone.rs @@ -16,7 +16,7 @@ use jiff::tz::{Offset, TimeZone}; use winnow::{ - combinator::{alt, delimited, opt, preceded, repeat}, + combinator::{alt, delimited, opt, peek, preceded, repeat, terminated}, stream::AsChar, token::{one_of, take_while}, ModalResult, Parser, @@ -25,13 +25,18 @@ use winnow::{ use super::primitive::{dec_uint, plus_or_minus}; pub(super) fn parse(input: &mut &str) -> ModalResult { - delimited("TZ=\"", preceded(opt(':'), alt((posix, iana))), '"').parse_next(input) + delimited( + "TZ=\"", + preceded(opt(':'), alt((terminated(posix, peek('"')), iana))), + '"', + ) + .parse_next(input) } /// Parse a posix (proleptic) timezone string (e.g., "UTC7", "JST-9"). /// -/// TODO: This implementation is incomplete. It currently only parses the -/// `STDOFFSET` part of the format. +/// This only parses the `STDOFFSET` part of the format. A rule with a `DST` +/// part or a quoted `<...>` name is left to [`iana`], which hands it to jiff. /// /// From the GNU docs: /// @@ -59,7 +64,10 @@ fn posix(input: &mut &str) -> ModalResult { } /// Parse an IANA (geographical) timezone string (e.g., "Europe/Paris"). If the -/// string is not a valid IANA timezone name, the UTC timezone is returned. +/// string is not a valid IANA timezone name, it is read as a full posix rule +/// (e.g., "CET-1CEST,M3.5.0,M10.5.0/3", "<+03>-3"). If that fails too, the UTC +/// timezone is returned, unless the string starts like a posix rule (e.g., +/// "EST5EDT,"), which is an error. /// /// Compatibility notes: /// @@ -90,7 +98,14 @@ fn iana(input: &mut &str) -> ModalResult { )), ) .map(|parts: Vec| parts.concat()) - .map(|s| TimeZone::get(&s).unwrap_or(TimeZone::UTC)) + .verify_map(|s| { + TimeZone::get(&s) + .or_else(|_| TimeZone::posix(&s)) + // Like glibc, use the US rules for a DST name without a rule (e.g., "EST5EDT") + .or_else(|_| TimeZone::posix(&format!("{s},M3.2.0,M11.1.0"))) + .ok() + .or_else(|| posix(&mut s.as_str()).is_err().then_some(TimeZone::UTC)) + }) .parse_next(input) } @@ -126,6 +141,7 @@ fn posix_offset(input: &mut &str) -> ModalResult { #[cfg(test)] mod tests { use super::*; + use jiff::Timestamp; #[test] fn tz_rule() { @@ -188,11 +204,37 @@ mod tests { ); } + // posix with a dst part or a quoted name: (input, offset in July, offset in January) + let july: Timestamp = "2026-07-01T12:00:00Z".parse().unwrap(); + let january: Timestamp = "2026-01-01T12:00:00Z".parse().unwrap(); + for (input, summer, winter) in [ + (r#"TZ="EST5EDT,M3.2.0,M11.1.0""#, -4 * 3600, -5 * 3600), + (r#"TZ="EST5EDT""#, -4 * 3600, -5 * 3600), + (r#"TZ="XYZ+5ABC""#, -4 * 3600, -5 * 3600), + (r#"TZ="CET-1CEST,M3.5.0,M10.5.0/3""#, 2 * 3600, 3600), + (r#"TZ="NZST-12NZDT,M9.5.0,M4.1.0/3""#, 12 * 3600, 13 * 3600), + (r#"TZ="<+03>-3""#, 3 * 3600, 3 * 3600), + (r#"TZ="<-05>5""#, -5 * 3600, -5 * 3600), + ( + r#"TZ="<+0530>-5:30""#, + 5 * 3600 + 30 * 60, + 5 * 3600 + 30 * 60, + ), + ] { + let mut s = input; + let tz = parse(&mut s).unwrap(); + assert_eq!(tz.to_offset(july).seconds(), summer, "{input}"); + assert_eq!(tz.to_offset(january).seconds(), winter, "{input}"); + } + // invalid for input in [ - r#"UTC"#, // missing "TZ=" - r#"tz="UTC""#, // lowercase "tz" - r#"TZ=UTC"#, // missing quotes + r#"UTC"#, // missing "TZ=" + r#"tz="UTC""#, // lowercase "tz" + r#"TZ=UTC"#, // missing quotes + r#"TZ="EST5EDT,""#, // empty dst rule + r#"TZ="EST5EDT,M3.2.0""#, // dst rule without an end + r#"TZ="CET-1CEST,M3.5.0,M99""#, // bad dst rule ] { let mut s = input; assert!(parse(&mut s).is_err(), "{input}");