Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 130 additions & 80 deletions src/items/offset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,23 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
/// Parse a timezone by name, with an optional numeric offset appended.
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?;
let tz = timezone_name_to_offset(nextword)?;
let (tz, takes_dst) = timezone_name_to_offset(nextword)?;

// A standard time zone followed by the word DST is its daylight saving
// time zone, one hour ahead: "EST DST" is EDT.
if takes_dst {
let start = input.checkpoint();
let word: ModalResult<&str> =
s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input);
if word == Ok("dst") {
return Ok(tz.merge(Offset {
negative: false,
hours: 1,
minutes: 0,
}));
}
input.reset(&start);
}

// 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.
Expand Down Expand Up @@ -284,88 +300,94 @@ fn timezone_offset_colonless(input: &mut &str) -> ModalResult<Offset> {
/// The full list of timezones can be extracted from
/// 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<Offset> {
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, 12, 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, 10, 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, 1, 0),
"brt" => (true, 3, 0),
"brst" => (true, 2, 0),
"b" => (false, 2, 0),
"ast" => (true, 4, 0),
"art" => (true, 3, 0),
"akst" => (true, 9, 0),
"akdt" => (true, 8, 0),
"adt" => (true, 3, 0),
"a" => (false, 1, 0),
///
/// The flag says whether the zone can be followed by DST. GNU date rejects it
/// after a daylight saving time zone, and after `t`.
fn timezone_name_to_offset(input: &str) -> ModalResult<(Offset, bool)> {
let (negative, hours, minutes, takes_dst) = match input {
"z" => (false, 0, 0, true),
"y" => (true, 12, 0, true),
"x" => (true, 11, 0, true),
"wet" => (false, 0, 0, true),
"west" => (false, 1, 0, false),
"wat" => (false, 1, 0, true),
"w" => (true, 10, 0, true),
"v" => (true, 9, 0, true),
"utc" => (false, 0, 0, true),
"ut" => (false, 0, 0, true),
"u" => (true, 8, 0, true),
"t" => (true, 7, 0, false),
"sst" => (true, 12, 0, true),
"sgt" => (false, 8, 0, true),
"sast" => (false, 2, 0, true),
"s" => (true, 6, 0, true),
"r" => (true, 5, 0, true),
"q" => (true, 4, 0, true),
"pst" => (true, 8, 0, true),
"pdt" => (true, 7, 0, false),
"p" => (true, 3, 0, true),
"o" => (true, 2, 0, true),
"nzst" => (false, 12, 0, true),
"nzdt" => (false, 13, 0, false),
"nst" => (true, 3, 30, true),
"ndt" => (true, 2, 30, false),
"n" => (true, 1, 0, true),
"mst" => (true, 7, 0, true),
"msk" => (false, 3, 0, true),
"msd" => (false, 4, 0, false),
"mez" => (false, 1, 0, true),
"mesz" => (false, 2, 0, false),
"mest" => (false, 2, 0, false),
"mdt" => (true, 6, 0, false),
"m" => (false, 12, 0, true),
"l" => (false, 11, 0, true),
"kst" => (false, 9, 0, true),
"k" => (false, 10, 0, true),
"jst" => (false, 9, 0, true),
"ist" => (false, 5, 30, true),
"i" => (false, 9, 0, true),
"hst" => (true, 10, 0, true),
"h" => (false, 8, 0, true),
"gst" => (false, 10, 0, true),
"gmt" => (false, 0, 0, true),
"g" => (false, 7, 0, true),
"f" => (false, 6, 0, true),
"est" => (true, 5, 0, true),
"eet" => (false, 2, 0, true),
"eest" => (false, 3, 0, false),
"edt" => (true, 4, 0, false),
"eat" => (false, 3, 0, true),
"e" => (false, 5, 0, true),
"d" => (false, 4, 0, true),
"cst" => (true, 6, 0, true),
"clt" => (true, 4, 0, true),
"clst" => (true, 3, 0, false),
"cet" => (false, 1, 0, true),
"cest" => (false, 2, 0, false),
"cdt" => (true, 5, 0, false),
"cat" => (false, 2, 0, true),
"c" => (false, 3, 0, true),
"bst" => (false, 1, 0, false),
"brt" => (true, 3, 0, true),
"brst" => (true, 2, 0, false),
"b" => (false, 2, 0, true),
"ast" => (true, 4, 0, true),
"art" => (true, 3, 0, true),
"akst" => (true, 9, 0, true),
"akdt" => (true, 8, 0, false),
"adt" => (true, 3, 0, false),
"a" => (false, 1, 0, true),
_ => return Err(ErrMode::Backtrack(ContextError::new())),
};

Ok(Offset {
negative,
hours,
minutes,
})
Ok((
Offset {
negative,
hours,
minutes,
},
takes_dst,
))
}

#[cfg(test)]
Expand Down Expand Up @@ -511,6 +533,34 @@ mod tests {
}
}

#[test]
fn timezone_name_with_dst() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a test with a full date in tests/time.rs too, e.g. 2026-01-10 10:00 EST DST

for (input, expected) in [
("est dst", off(true, 4, 0)), // EST is UTC-5, so EST DST is UTC-4
("cet dst", off(false, 2, 0)), // more than one space
("utc dst", off(false, 1, 0)), // UTC
("ist dst", off(false, 6, 30)), // offset with minutes (ist=+5:30)
("a dst", off(false, 2, 0)), // military letter (a=+1)
] {
let mut s = input;
assert_eq!(timezone_name_offset(&mut s).unwrap(), expected, "{input}");
assert!(s.is_empty(), "{input}");
}

for input in [
"edt dst", // already a daylight saving time zone
"t dst", // rejected by GNU date as well
"estdst", // DST must be a separate word
"est dstx",
] {
let mut s = input;
assert!(
timezone_name_offset(&mut s).is_err() || !s.is_empty(),
"{input}"
);
}
}

#[test]
fn offset_followed_by_relative() {
// "+8 years" is a relative item, so it is not an offset.
Expand Down
102 changes: 102 additions & 0 deletions tests/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,107 @@ fn test_time_correction(#[case] input: &str, #[case] expected: &str) {
check_time(input, expected, "%H:%M:%S%.9f", None);
}

#[rstest]
#[case::est("2026-01-10 10:00 EST DST", "2026-01-10 14:00:00")]
#[case::lowercase("2026-01-10 10:00 est dst", "2026-01-10 14:00:00")]
#[case::cet("2026-01-10 10:00 CET DST", "2026-01-10 08:00:00")]
#[case::ist_with_minutes("2026-01-10 10:00 IST DST", "2026-01-10 03:30:00")]
fn test_time_zone_dst(#[case] input: &str, #[case] expected: &str) {
check_time(input, expected, "%Y-%m-%d %H:%M:%S", None);
}

#[test]
fn test_time_zone_names() {
// Every named time zone, alone and followed by DST, as GNU date 9.7 reads
// them. None means GNU date rejects DST after that zone.
for (name, expected, expected_dst) in [
("Z", "2026-01-10 10:00", Some("2026-01-10 09:00")),
("Y", "2026-01-10 22:00", Some("2026-01-10 21:00")),
("X", "2026-01-10 21:00", Some("2026-01-10 20:00")),
("WET", "2026-01-10 10:00", Some("2026-01-10 09:00")),
("WEST", "2026-01-10 09:00", None),
("WAT", "2026-01-10 09:00", Some("2026-01-10 08:00")),
("W", "2026-01-10 20:00", Some("2026-01-10 19:00")),
("V", "2026-01-10 19:00", Some("2026-01-10 18:00")),
("UTC", "2026-01-10 10:00", Some("2026-01-10 09:00")),
("UT", "2026-01-10 10:00", Some("2026-01-10 09:00")),
("U", "2026-01-10 18:00", Some("2026-01-10 17:00")),
("T", "2026-01-10 17:00", None),
("SST", "2026-01-10 22:00", Some("2026-01-10 21:00")),
("SGT", "2026-01-10 02:00", Some("2026-01-10 01:00")),
("SAST", "2026-01-10 08:00", Some("2026-01-10 07:00")),
("S", "2026-01-10 16:00", Some("2026-01-10 15:00")),
("R", "2026-01-10 15:00", Some("2026-01-10 14:00")),
("Q", "2026-01-10 14:00", Some("2026-01-10 13:00")),
("PST", "2026-01-10 18:00", Some("2026-01-10 17:00")),
("PDT", "2026-01-10 17:00", None),
("P", "2026-01-10 13:00", Some("2026-01-10 12:00")),
("O", "2026-01-10 12:00", Some("2026-01-10 11:00")),
("NZST", "2026-01-09 22:00", Some("2026-01-09 21:00")),
("NZDT", "2026-01-09 21:00", None),
("NST", "2026-01-10 13:30", Some("2026-01-10 12:30")),
("NDT", "2026-01-10 12:30", None),
("N", "2026-01-10 11:00", Some("2026-01-10 10:00")),
("MST", "2026-01-10 17:00", Some("2026-01-10 16:00")),
("MSK", "2026-01-10 07:00", Some("2026-01-10 06:00")),
("MSD", "2026-01-10 06:00", None),
("MEZ", "2026-01-10 09:00", Some("2026-01-10 08:00")),
("MESZ", "2026-01-10 08:00", None),
("MEST", "2026-01-10 08:00", None),
("MDT", "2026-01-10 16:00", None),
("M", "2026-01-09 22:00", Some("2026-01-09 21:00")),
("L", "2026-01-09 23:00", Some("2026-01-09 22:00")),
("KST", "2026-01-10 01:00", Some("2026-01-10 00:00")),
("K", "2026-01-10 00:00", Some("2026-01-09 23:00")),
("JST", "2026-01-10 01:00", Some("2026-01-10 00:00")),
("IST", "2026-01-10 04:30", Some("2026-01-10 03:30")),
("I", "2026-01-10 01:00", Some("2026-01-10 00:00")),
("HST", "2026-01-10 20:00", Some("2026-01-10 19:00")),
("H", "2026-01-10 02:00", Some("2026-01-10 01:00")),
("GST", "2026-01-10 00:00", Some("2026-01-09 23:00")),
("GMT", "2026-01-10 10:00", Some("2026-01-10 09:00")),
("G", "2026-01-10 03:00", Some("2026-01-10 02:00")),
("F", "2026-01-10 04:00", Some("2026-01-10 03:00")),
("EST", "2026-01-10 15:00", Some("2026-01-10 14:00")),
("EET", "2026-01-10 08:00", Some("2026-01-10 07:00")),
("EEST", "2026-01-10 07:00", None),
("EDT", "2026-01-10 14:00", None),
("EAT", "2026-01-10 07:00", Some("2026-01-10 06:00")),
("E", "2026-01-10 05:00", Some("2026-01-10 04:00")),
("D", "2026-01-10 06:00", Some("2026-01-10 05:00")),
("CST", "2026-01-10 16:00", Some("2026-01-10 15:00")),
("CLT", "2026-01-10 14:00", Some("2026-01-10 13:00")),
("CLST", "2026-01-10 13:00", None),
("CET", "2026-01-10 09:00", Some("2026-01-10 08:00")),
("CEST", "2026-01-10 08:00", None),
("CDT", "2026-01-10 15:00", None),
("CAT", "2026-01-10 08:00", Some("2026-01-10 07:00")),
("C", "2026-01-10 07:00", Some("2026-01-10 06:00")),
("BST", "2026-01-10 09:00", None),
("BRT", "2026-01-10 13:00", Some("2026-01-10 12:00")),
("BRST", "2026-01-10 12:00", None),
("B", "2026-01-10 08:00", Some("2026-01-10 07:00")),
("AST", "2026-01-10 14:00", Some("2026-01-10 13:00")),
("ART", "2026-01-10 13:00", Some("2026-01-10 12:00")),
("AKST", "2026-01-10 19:00", Some("2026-01-10 18:00")),
("AKDT", "2026-01-10 18:00", None),
("ADT", "2026-01-10 13:00", None),
("A", "2026-01-10 09:00", Some("2026-01-10 08:00")),
] {
let input = format!("2026-01-10 10:00 {name}");
check_time(&input, expected, "%Y-%m-%d %H:%M", None);

let input = format!("{input} DST");
match expected_dst {
Some(expected) => check_time(&input, expected, "%Y-%m-%d %H:%M", None),
None => assert!(
parse_datetime::parse_datetime(&input).is_err(),
"Input string '{input}' did not produce an error when parsing"
),
}
}
}

#[rstest]
#[case::plus_12("11:34:56+12:00", "2022-06-09 23:34:56")]
#[case::minus_12("12:34:56-12:00", "2022-06-11 00:34:56")]
Expand Down Expand Up @@ -121,6 +222,7 @@ fn test_time_correction_with_overflow(#[case] input: &str, #[case] expected: &st
#[case("10:59am+01")]
#[case("10:59+01pm")]
#[case("23:59:59+00:00:00")]
#[case("2026-01-10 10:00 EDT DST")]
fn test_time_invalid(#[case] input: &str) {
let result = parse_datetime::parse_datetime(input);
assert_eq!(
Expand Down
Loading