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
36 changes: 31 additions & 5 deletions src/uu/date/src/format_modifiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
//! - `_`: Pad with spaces instead of zeros
//! - `0`: Pad with zeros (default for numeric fields)
//! - `^`: Convert to uppercase
//! - `#`: Use opposite case (uppercase becomes lowercase and vice versa)
//! - `#`: Use opposite case (uppercase becomes lowercase and vice versa).
//! Only the specifiers that emit a name (`%a %A %b %B %h %p %P %Z`) honor it,
//! and there it takes precedence over `^`
//! - `+`: Force display of sign (+ for positive, - for negative)
//!
//! ### Width
Expand Down Expand Up @@ -265,6 +267,24 @@ fn is_text_specifier(specifier: &str) -> bool {
)
}

/// Returns true if GNU's conversion for `specifier` honors the `#`
/// (opposite case) flag.
///
/// Only the conversions that emit a locale name or abbreviation look at `#`.
/// Composite specifiers such as `%c` and `%r` are expanded recursively without
/// it, so `%#c` and `%#r` print like `%c` and `%r`, while `^` still reaches
/// into that expansion.
///
/// This deliberately duplicates the list in `is_text_specifier` rather than
/// calling it: that one classifies specifiers for padding, and the two sets
/// only happen to coincide today.
fn honors_opposite_case(specifier: &str) -> bool {
matches!(
specifier.chars().last(),
Some('A' | 'a' | 'B' | 'b' | 'h' | 'Z' | 'p' | 'P')
)
}

/// Returns true if the specifier defaults to space padding.
/// This includes text specifiers and numeric specifiers like %e and %k
/// that use blank-padding by default in GNU date.
Expand Down Expand Up @@ -391,10 +411,8 @@ fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result<String, Forma
}
'^' => {
uppercase = true;
swap_case = false; // ^ overrides #
}
'#' if !uppercase => {
// Only apply # if ^ hasn't been set
'#' => {
swap_case = true;
}
'+' => {
Expand All @@ -406,7 +424,15 @@ fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result<String, Forma
}
}

// Apply case modifications (uppercase takes precedence over swap_case)
// GNU applies the case flags inside each conversion rather than to the
// whole rendered string, so `#` only reaches the specifiers that emit a
// name, and where it applies it wins over `^`: `%^#p` and `%#^p` both
// print `pm`.
let swap_case = swap_case && honors_opposite_case(specifier);
// `%P` is the lower-case form of `%p` and stays lower case whatever the
// flags ask for, so `%^P` prints `pm`.
let uppercase = uppercase && !swap_case && !specifier.ends_with('P');

if uppercase {
result = result.to_uppercase();
} else if swap_case {
Expand Down
52 changes: 51 additions & 1 deletion tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2742,7 +2742,7 @@ fn test_date_format_modifier_combined_flags() {

#[test]
fn test_date_format_modifier_case_precedence() {
// Test that ^ (uppercase) takes precedence over # (swap case) regardless of order
// "June" is upper-cased by either flag, so %^#B and %#^B agree
new_ucmd!()
.env("TZ", "UTC")
.env("LC_ALL", "C")
Expand All @@ -2758,6 +2758,56 @@ fn test_date_format_modifier_case_precedence() {
.stdout_is("JUNE\n");
}

#[test]
fn test_date_format_modifier_case_flags_per_specifier() {
// GNU applies `^` and `#` inside each conversion instead of to the whole
// rendered string. Only the specifiers that emit a name honor `#`, and
// there it wins over `^`; the composite `%c` and `%r` are expanded without
// it, and `%P` stays lower case whatever the flags ask for.
let cases = [
("%c", "Sat Jun 15 13:05:03 2024"),
("%^c", "SAT JUN 15 13:05:03 2024"),
("%#c", "Sat Jun 15 13:05:03 2024"),
("%r", "01:05:03 PM"),
("%^r", "01:05:03 PM"),
("%#r", "01:05:03 PM"),
("%p", "PM"),
("%^p", "PM"),
("%#p", "pm"),
("%^#p", "pm"),
("%#^p", "pm"),
("%P", "pm"),
("%^P", "pm"),
("%#P", "pm"),
("%^#P", "pm"),
("%Z", "UTC"),
("%^Z", "UTC"),
("%#Z", "utc"),
("%^#Z", "utc"),
];
for (format, expected) in cases {
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC")
.arg("-d")
.arg("2024-06-15 13:05:03")
.arg(format!("+{format}"))
.succeeds()
.stdout_is(format!("{expected}\n"));
}
}

#[test]
fn test_date_format_modifier_case_flags_on_named_zone() {
// `#` lower-cases a non-UTC abbreviation too, and `^` does not cancel it
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "America/New_York")
.args(&["-d", "2024-06-15", "+%Z %^Z %#Z %^#Z"])
.succeeds()
.stdout_is("EDT EDT edt edt\n");
}

#[test]
fn test_date_format_modifier_multiple() {
// Test multiple modifiers in one format string
Expand Down
Loading