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
199 changes: 127 additions & 72 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz unparseable
// spell-checker:ignore ohos OHOS tzdata tzdb tzif zoneinfo euctw nonrelative wednes

mod date_names;
mod format_modifiers;
mod locale;

Expand Down Expand Up @@ -88,6 +89,64 @@
const OPT_UNIVERSAL: &str = "universal";
const OPT_UNIVERSAL_2: &str = "utc";

// GNU treats `-I` followed by a space as a flag and the next word as a format operand. Clap would
// consume that word as the optional value, so translate attached `-I<fmt>` forms, including short
// option groups such as `-uIhours`, to `--iso-8601=<fmt>` and let Clap handle bare `-I`. A preceding

Check warning on line 94 in src/uu/date/src/date.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'Ihours' (file:'src/uu/date/src/date.rs', line:94)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@anastygnome could you do something about the cspell warnings?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can't find those, where are they?

@xtqqczze xtqqczze Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You can view the warnings in the GitHub interface, under "Files changed". Alternatively, you can follow the instructions in DEVELOPMENT.md to install cspell locally.

// value-taking short option owns the rest of the group, so stop before its attached value.
fn normalize_iso_8601_args(args: impl uucore::Args) -> impl Iterator<Item = OsString> {
args.scan(false, |options_ended, arg| {
if *options_ended {
return Some(vec![arg]);
}

let Some(arg_str) = arg.to_str() else {
return Some(vec![arg]);
};

if arg_str == "--" {
*options_ended = true;
return Some(vec![arg]);
}

let Some(iso_index) = arg_str
.strip_prefix('-')
.filter(|arg| !arg.starts_with('-'))
.and_then(|arg| {
let bytes = arg.as_bytes();
let mut index = 0;
while let Some(&byte) = bytes.get(index) {
match byte {
b'I' => return Some(index),
b'd' | b'f' | b'r' | b's' => return None,
_ => index += 1,
}
}
None
})
else {
return Some(vec![arg]);
};

let prefix = &arg_str[..=iso_index];
let value = &arg_str[iso_index + 2..];
let mut normalized = Vec::with_capacity(2);
if prefix.len() > 1 {
normalized.push(OsString::from(prefix));
}
if value.is_empty() && prefix.len() == 1 {
normalized.push(arg);
} else if value.is_empty() {
normalized.push(OsString::from("-I"));
} else {
let value = value.strip_prefix('=').unwrap_or(value);
normalized.push(OsString::from(format!("--{OPT_ISO_8601}={value}")));
}

Some(normalized)
})
.flatten()
}

#[derive(Debug, thiserror::Error)]
enum DateError {
#[error("{}", translate!("date-error-write", "error" => strip_errno(.0)))]
Expand Down Expand Up @@ -384,7 +443,8 @@
#[uucore::main]
#[allow(clippy::cognitive_complexity)]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
let matches =
uucore::clap_localization::handle_clap_result(uu_app(), normalize_iso_8601_args(args))?;

let date_source = if let Some(date_os) = matches
.get_many::<OsString>(OPT_DATE)
Expand Down Expand Up @@ -424,7 +484,7 @@
let arg = operand_for_error(fmt);
// if an optional Format String was found but the user has not provided an input date
// GNU prints an invalid date Error
if !matches!(date_source, DateSource::Human(_)) {
if matches!(date_source, DateSource::Now) {
return Err(Box::new(DateError::InvalidDate { date: arg }));
}
// If the user did provide an input date with the --date flag and the Format String is
Expand Down Expand Up @@ -729,8 +789,9 @@
DATE, HOURS, MINUTES, SECONDS, NS,
]))
.num_args(0..=1)
.require_equals(true)
.default_missing_value(OPT_DATE)
.action(ArgAction::Append)
.overrides_with(OPT_ISO_8601)
.help(translate!("date-help-iso-8601")),
)
.arg(
Expand Down Expand Up @@ -781,6 +842,7 @@
.long(OPT_SET)
.value_name("STRING")
.allow_hyphen_values(true)
.conflicts_with_all([OPT_DATE, OPT_FILE, OPT_REFERENCE, OPT_RESOLUTION])
.help({
#[cfg(not(target_os = "redox"))]
{
Expand Down Expand Up @@ -1712,9 +1774,7 @@
_ => return None,
};

let day_number = day.parse::<u16>().ok()?;
let month_number = month.parse::<u16>().ok()?;
if !(1..=31).contains(&day_number) || !(1..=12).contains(&month_number) {
if !is_calendar_day(day) || !is_calendar_month(month) {
return None;
}

Expand Down Expand Up @@ -1773,43 +1833,6 @@
time_rewritten.or(Some(rewritten))
}

fn is_weekday_name(token: &str) -> bool {
// GNU documents these forms in its "Day of week items" section:
// https://www.gnu.org/software/coreutils/manual/html_node/Day-of-week-items.html
// Full names, three-letter abbreviations with an optional period, and the
// special `tues`, `wednes`, `thur`, and `thurs` forms are accepted. Single-
// letter forms are intentionally excluded because they are ambiguous with
// other syntax.
matches!(
token.to_ascii_lowercase().as_str(),
"monday"
| "mon"
| "mon."
| "tuesday"
| "tue"
| "tue."
| "tues"
| "wednesday"
| "wed"
| "wed."
| "wednes"
| "thursday"
| "thu"
| "thu."
| "thur"
| "thurs"
| "friday"
| "fri"
| "fri."
| "saturday"
| "sat"
| "sat."
| "sunday"
| "sun"
| "sun."
)
}

fn looks_like_explicit_calendar_date(input: &str) -> bool {
let Some(token) = input.split_whitespace().next() else {
return false;
Expand All @@ -1826,26 +1849,42 @@
|| (token.chars().all(|c| c.is_ascii_digit()) && token.len() >= 6)
}

fn is_weekday_prefix(token: &str) -> bool {
token.parse::<u32>().is_ok()
|| matches!(
token.to_ascii_lowercase().as_str(),
"first"
| "second"
| "third"
| "fourth"
| "fifth"
| "sixth"
| "seventh"
| "eighth"
| "ninth"
| "tenth"
| "eleventh"
| "twelfth"
| "last"
| "next"
| "this"
)
fn is_calendar_number(token: &str, range: std::ops::RangeInclusive<u8>) -> bool {
let token = token.strip_suffix(',').unwrap_or(token);
token
.parse::<u8>()
.is_ok_and(|value| range.contains(&value))
}

fn is_calendar_day(token: &str) -> bool {
is_calendar_number(token, 1..=31)
}

fn is_calendar_month(token: &str) -> bool {
Comment thread
anastygnome marked this conversation as resolved.
is_calendar_number(token, 1..=12)
}

fn is_calendar_year(token: &str) -> bool {
let token = token.strip_suffix(',').unwrap_or(token);
(2..=4).contains(&token.len()) && token.chars().all(|c| c.is_ascii_digit())
}

fn named_date_end(tokens: &[&str], date_index: usize) -> Option<usize> {
// Keep the day, month, and optional year together so a following number is
// not mistaken for an ordinal weekday prefix.
let first = tokens.get(date_index)?;
let second = tokens.get(date_index + 1)?;

let month_day = date_names::is_month_name(first) && is_calendar_day(second);
let day_month = is_calendar_day(first) && date_names::is_month_name(second);
if !month_day && !day_month {
return None;
}

let has_year = tokens
.get(date_index + 2)
.is_some_and(|year| is_calendar_year(year));
Some(date_index + if has_year { 2 } else { 1 })
}

/// Drop weekday items when an explicit calendar date is present.
Expand All @@ -1870,23 +1909,29 @@
.iter()
.map(|&(start, end)| &input[start..end])
.collect();
let date_index = tokens.iter().position(|token| {
!token.starts_with("TZ=\"")
let (date_index, named_date_end) = tokens.iter().enumerate().find_map(|(index, token)| {
let numeric_date = !token.starts_with("TZ=\"")
&& !token.ends_with(',')
&& looks_like_explicit_calendar_date(token)
&& looks_like_explicit_calendar_date(token);
if numeric_date {
Some((index, None))
} else {
named_date_end(&tokens, index).map(|end| (index, Some(end)))
}
})?;
let weekday_index = tokens.iter().enumerate().find_map(|(index, token)| {
let weekday = token.strip_suffix(',').unwrap_or(token);
(index != date_index && is_weekday_name(weekday)).then_some(index)
(index != date_index && date_names::is_weekday_name(weekday)).then_some(index)
})?;

let mut removed = vec![false; tokens.len()];
removed[weekday_index] = true;
if weekday_index > 0
&& weekday_index - 1 != date_index
&& is_weekday_prefix(tokens[weekday_index - 1])
if let Some(prefix_index) = weekday_index.checked_sub(1)
&& prefix_index != date_index
&& !named_date_end.is_some_and(|end| (date_index + 1..=end).contains(&prefix_index))
&& date_names::is_weekday_prefix(tokens[prefix_index])
{
removed[weekday_index - 1] = true;
removed[prefix_index] = true;
}

let mut result = String::with_capacity(input.len());
Expand All @@ -1899,7 +1944,7 @@
cursor = end;
}
result.push_str(&input[cursor..]);
Some(result)
Some(result.trim().to_owned())
}

/// Normalize offsets whose minute component exceeds 59 into total minutes.
Expand Down Expand Up @@ -2490,6 +2535,16 @@
assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched
}

#[test]
fn test_strip_weekday_from_day_month_dates() {
for input in ["Monday 17 August 2026", "Wednesday 17 August 2026"] {
assert_eq!(
strip_weekday_for_explicit_date(input).as_deref(),
Some("17 August 2026")
);
}
}

/// Run one date through the shared writer used by the display loop and
/// the `-s` echo, returning what it produced.
fn write_date_with_settings(date: Zoned, settings: Settings) -> String {
Expand Down
99 changes: 99 additions & 0 deletions src/uu/date/src/date_names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

//! GNU-compatible date-name matching for input strings.

/// GNU documents these forms in its "Calendar date items" section:
/// <https://www.gnu.org/software/coreutils/manual/html_node/Calendar-date-items.html>
/// Full names, three-letter abbreviations, and the special `Sept` form are
/// accepted.
pub(super) fn is_month_name(token: &str) -> bool {
matches!(
token.to_ascii_lowercase().as_str(),
"january"
| "jan"
| "february"
| "feb"
| "march"
| "mar"
| "april"
| "apr"
| "may"
| "june"
| "jun"
| "july"
| "jul"
| "august"
| "aug"
| "september"
| "sep"
| "sept"
| "october"
| "oct"
| "november"
| "nov"
| "december"
| "dec"
)
}

/// GNU documents these forms in its "Day of week items" section:
/// <https://www.gnu.org/software/coreutils/manual/html_node/Day-of-week-items.html>
/// Full names, three-letter abbreviations with an optional period, and the
/// special `tues`, `wednes`, `thur`, and `thurs` forms are accepted. Single-

Check warning on line 45 in src/uu/date/src/date_names.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'wednes' (file:'src/uu/date/src/date_names.rs', line:45)
/// letter forms are intentionally excluded because they are ambiguous with
/// other syntax.
pub(super) fn is_weekday_name(token: &str) -> bool {
matches!(
token.to_ascii_lowercase().as_str(),
"monday"
| "mon"
| "mon."
| "tuesday"
| "tue"
| "tue."
| "tues"
| "wednesday"
| "wed"
| "wed."
| "wednes"

Check warning on line 61 in src/uu/date/src/date_names.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'wednes' (file:'src/uu/date/src/date_names.rs', line:61)
| "thursday"
| "thu"
| "thu."
| "thur"
| "thurs"
| "friday"
| "fri"
| "fri."
| "saturday"
| "sat"
| "sat."
| "sunday"
| "sun"
| "sun."
)
}

pub(super) fn is_weekday_prefix(token: &str) -> bool {
token.parse::<u32>().is_ok()
|| matches!(
token.to_ascii_lowercase().as_str(),
"first"
| "second"
| "third"
| "fourth"
| "fifth"
| "sixth"
| "seventh"
| "eighth"
| "ninth"
| "tenth"
| "eleventh"
| "twelfth"
| "last"
| "next"
| "this"
)
}
Loading
Loading