Skip to content
Open
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
49 changes: 36 additions & 13 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz unparseable
// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST MEST foobarbaz unparseable

mod format_modifiers;
mod locale;
Expand Down Expand Up @@ -1039,8 +1039,12 @@ fn resolve_tz_abbreviation(word: &str) -> Option<TimeZone> {
/// (e.g. "10:30 EST").
///
/// If a trailing abbreviation is found and the rest of the string is a parsable
/// date, returns `Some(Zoned)`. Returns `None` if no abbreviation is detected or
/// if parsing fails, indicating that standard parsing should be attempted.
/// date that could still legally take a timezone, returns `Some(Zoned)`.
///
/// Returns `None` when no abbreviation is detected, when parsing fails, or when
/// the remainder already carries zone information or cannot take a zone at all
/// (GNU `date` rejects those). In every `None` case the caller should fall back
/// to standard parsing, which reports the error.
fn try_parse_with_abbreviation<S: AsRef<str>>(date_str: S, now: &Zoned) -> Option<Zoned> {
let s = date_str.as_ref();

Expand All @@ -1050,21 +1054,40 @@ fn try_parse_with_abbreviation<S: AsRef<str>>(date_str: S, now: &Zoned) -> Optio

let date_part = s.trim_end_matches(last_word).trim();

// Reject inputs that specify a timezone twice, e.g. "EST EST" or "EST PST":
// GNU `date` considers these invalid. If what remains after stripping the
// trailing abbreviation is itself a bare timezone abbreviation, don't rescue
// it here; let the standard parser reject the whole string.
if date_part
.split_whitespace()
.last()
.is_some_and(|w| resolve_tz_abbreviation(w).is_some())
{
// A timestamp is complete on its own and cannot take a timezone, so GNU
// `date` rejects "@0 EST". The sigil says so without parsing anything.
if date_part.starts_with('@') {
return None;
}

// Parse in the target timezone so "10:30 EDT" means 10:30 in EDT.
let parsed = parse_datetime::parse_datetime_at_date(now.clone(), date_part).ok()?;
let zoned = parsed.into_zoned()?.datetime().to_zoned(tz).ok()?;
let zoned = parsed.into_zoned()?;

// Only rescue input that could still legally take a timezone. GNU `date`
// rejects a trailing abbreviation when `date_part` already carries zone
// information ("6:00PM GMT-1 EST", "023-060 MEST", "12:00 EST EST"), and
// `parse_datetime` hands back the zone the input named, or `now`'s zone when
// it named none. So the value parsed above usually settles the question at
// no extra cost.
if zoned.time_zone() != now.time_zone() {
return None;
}

// That comparison is blind to a zone whose offset coincides with `now`'s
// ("12:00 UTC EST" under `-u`). Only then ask the parser directly: appending
// an abbreviation it already understands fails if `date_part` carries a zone
// of its own, which avoids duplicating its offset grammar here. Gated on the
// input naming a zone at all, so plain dates and times skip the second
// parse, which matters because `-f` runs this once per line.
Comment on lines +1065 to +1082

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.

do we need that long comments?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair. I'll cut them to the two things that aren't visible from the code: that parse_datetime returns now's zone when the input named none, which is what makes the comparison work, and that the second parse is gated so -f stays at one parse per line. The example inputs are already in the test, so they don't need repeating here.

let names_zone = date_part.contains('+')
|| date_part.contains(|c: char| c.is_ascii_alphabetic())
|| date_part.split_whitespace().any(|w| w.starts_with('-'));
if names_zone {
parse_datetime::parse_datetime_at_date(now.clone(), format!("{date_part} EST")).ok()?;
}

let zoned = zoned.datetime().to_zoned(tz).ok()?;

// The trailing abbreviation only describes the *input* timezone. For display,
// re-zone to the system timezone (i.e. `now`'s zone, which is UTC under `-u`).
Expand Down
47 changes: 46 additions & 1 deletion tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//
// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST KST uueuu ueuu vasárnap június január distros
// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST MESZ KST uueuu ueuu vasárnap június január distros
// spell-checker: ignore: uppercases

use std::cmp::Ordering;
Expand Down Expand Up @@ -101,6 +101,51 @@ fn test_large_year_default_output_boundary() {
.stderr_contains("invalid date");
}

#[test]
fn test_date_rejects_input_that_cannot_take_a_timezone() {
// A trailing timezone abbreviation must not rescue an input that already
// carries zone information, or that cannot take a zone at all. GNU date
// rejects all of these.
for input in [
"Jan 23 6:00PM GMT-1 EST", // offset plus abbreviation
"023-060 MEST", // time with offset, plus abbreviation
"2024-01-15 12:00 EST EST", // the same abbreviation twice
"@0 EST", // a timestamp cannot take a zone
"2024-01-15 12:00 UTC EST", // a named zone whose offset matches TZ
"2024-01-15 12:00 GMT EST", // likewise, spelled differently
"2024-01-15 12:00 +0000 EST", // a numeric offset matching TZ
"2024-01-15 12:00 -0500 EST", // a standalone negative offset
"UTC 2024-01-15 12:00 EST", // zone stated before the date
] {
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC0")
.args(&["-d", input])
.fails_with_code(1)
.stderr_contains("invalid date");
}
}

#[test]
fn test_date_accepts_gnu_timezone_abbreviations() {
// Abbreviations GNU date accepts, with the UTC time they map to.
for (input, expected) in [
("2024-01-15 12:00 MEZ", "11:00\n"),
("2024-01-15 12:00 MESZ", "10:00\n"),
("2024-01-15 12:00 MEST", "10:00\n"),
("2024-01-15 12:00 KST", "03:00\n"),
("2024-01-15 12:00 EST", "17:00\n"),
("2024-01-15 12:00 IST", "06:30\n"),
] {
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC0")
.args(&["-u", "-d", input, "+%H:%M"])
.succeeds()
.stdout_is(expected);
}
}

#[test]
fn test_format_option_not_to_capture_other_valid_arguments() {
new_ucmd!()
Expand Down
Loading