mirror of
https://github.com/uutils/coreutils.git
synced 2026-06-10 15:48:22 -07:00
date: fix %+ and %_ modifier edge cases (#10999)
* date: fix %+ and %_ modifier edge cases Fixes #10957: 1. %+ without explicit width now correctly omits sign for years with <= 4 digits. Sign is only added when explicit width is provided or year exceeds default width. 2. %_ without explicit width now correctly pads to the specifier's default width (e.g., %_m produces " 6" instead of "6"). Changes: - Add get_default_width() function mapping specifiers to POSIX default widths - Modify apply_modifiers() to accept explicit_width parameter - Update tests for new behavior Test results: - date -d '1999-06-01' '+%+Y' -> "1999" (was "+1999") - date -d '1999-06-01' '+%_m' -> " 6" (was "6")
This commit is contained in:
@@ -146,7 +146,8 @@ fn format_with_modifiers(
|
||||
if !flags.is_empty() || !width_str.is_empty() {
|
||||
// Apply modifiers to the formatted value
|
||||
let width: usize = width_str.parse().unwrap_or(0);
|
||||
let modified = apply_modifiers(&formatted, flags, width, spec);
|
||||
let explicit_width = !width_str.is_empty();
|
||||
let modified = apply_modifiers(&formatted, flags, width, spec, explicit_width);
|
||||
result.push_str(&modified);
|
||||
} else {
|
||||
// No modifiers, use formatted value as-is
|
||||
@@ -174,6 +175,59 @@ fn is_text_specifier(specifier: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn is_space_padded_specifier(specifier: &str) -> bool {
|
||||
matches!(
|
||||
specifier.chars().last(),
|
||||
Some('A' | 'a' | 'B' | 'b' | 'h' | 'Z' | 'p' | 'P' | 'e' | 'k' | 'l')
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the default width for a specifier.
|
||||
/// This is used when a flag like `_` is used without an explicit width.
|
||||
fn get_default_width(specifier: &str) -> usize {
|
||||
match specifier.chars().last() {
|
||||
// Day of month: 2 digits (01-31)
|
||||
Some('d') | Some('e') => 2,
|
||||
// Month: 2 digits (01-12)
|
||||
Some('m') => 2,
|
||||
// Hour: 2 digits (00-23)
|
||||
Some('H') | Some('k') => 2,
|
||||
// Hour (12-hour): 2 digits (01-12)
|
||||
Some('I') | Some('l') => 2,
|
||||
// Minute: 2 digits (00-59)
|
||||
Some('M') => 2,
|
||||
// Second: 2 digits (00-60)
|
||||
Some('S') => 2,
|
||||
// Year (2-digit): 2 digits
|
||||
Some('y') => 2,
|
||||
// Day of year: 3 digits (001-366)
|
||||
Some('j') => 3,
|
||||
// Week number: 2 digits (00-53)
|
||||
Some('U') | Some('W') | Some('V') => 2,
|
||||
// Day of week: 1 digit (0-6 or 1-7)
|
||||
Some('w') | Some('u') => 1,
|
||||
// Century: 2 digits (00-99)
|
||||
Some('C') => 2,
|
||||
// Full year: 4 digits
|
||||
Some('Y') | Some('G') => 4,
|
||||
// ISO week year (2-digit): 2 digits
|
||||
Some('g') => 2,
|
||||
// Epoch seconds: typically 10 digits (but variable)
|
||||
Some('s') => 0,
|
||||
// Nanoseconds: 9 digits
|
||||
Some('N') => 9,
|
||||
// Quarter: 1 digit
|
||||
Some('q') => 1,
|
||||
// Timezone offset: varies
|
||||
Some('z') => 0,
|
||||
// Text specifiers have no default width
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip default padding (leading zeros or leading spaces) from a value,
|
||||
/// preserving at least one character.
|
||||
fn strip_default_padding(value: &str) -> String {
|
||||
@@ -203,11 +257,23 @@ fn strip_default_padding(value: &str) -> String {
|
||||
/// which determines the default padding character (space for text, zero for numeric).
|
||||
/// Flags are processed in order so that when conflicting flags appear,
|
||||
/// the last one takes precedence (e.g., `_+` means `+` wins for padding).
|
||||
fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> String {
|
||||
///
|
||||
/// The `explicit_width` parameter indicates whether a width was explicitly
|
||||
/// specified in the format string (true) or if width is 0 (false).
|
||||
fn apply_modifiers(
|
||||
value: &str,
|
||||
flags: &str,
|
||||
width: usize,
|
||||
specifier: &str,
|
||||
explicit_width: bool,
|
||||
) -> String {
|
||||
let mut result = value.to_string();
|
||||
|
||||
// Determine default pad character based on specifier type
|
||||
let default_pad = if is_text_specifier(specifier) {
|
||||
// Determine default pad character based on specifier type.
|
||||
// Text specifiers (month names, etc.) and numeric specifiers like %e, %k, %l
|
||||
// default to space padding; other numeric specifiers default to zero padding.
|
||||
let default_pad = if is_space_padded_specifier(specifier) {
|
||||
' '
|
||||
} else {
|
||||
'0'
|
||||
@@ -219,6 +285,7 @@ fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> S
|
||||
let mut uppercase = false;
|
||||
let mut swap_case = false;
|
||||
let mut force_sign = false;
|
||||
let mut underscore_flag = false;
|
||||
|
||||
for flag in flags.chars() {
|
||||
match flag {
|
||||
@@ -228,6 +295,7 @@ fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> S
|
||||
'_' => {
|
||||
no_pad = false;
|
||||
pad_char = ' ';
|
||||
underscore_flag = true;
|
||||
}
|
||||
'0' => {
|
||||
no_pad = false;
|
||||
@@ -271,30 +339,48 @@ fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> S
|
||||
return strip_default_padding(&result);
|
||||
}
|
||||
|
||||
// Handle padding flag without explicit width: use default width
|
||||
// This applies when _ or 0 flag overrides the default padding character
|
||||
// and no explicit width is specified (e.g., %_m, %0e)
|
||||
let effective_width = if !explicit_width && (underscore_flag || pad_char != default_pad) {
|
||||
get_default_width(specifier)
|
||||
} else {
|
||||
width
|
||||
};
|
||||
|
||||
// Handle width smaller than result: strip default padding to fit
|
||||
if width > 0 && width < result.len() {
|
||||
if effective_width > 0 && effective_width < result.len() {
|
||||
return strip_default_padding(&result);
|
||||
}
|
||||
|
||||
// Strip leading zeros when switching to space padding on numeric fields
|
||||
if pad_char == ' '
|
||||
&& !is_text_specifier(specifier)
|
||||
&& result.starts_with('0')
|
||||
&& result.len() >= 2
|
||||
{
|
||||
result = strip_default_padding(&result);
|
||||
// Strip default padding when switching pad characters on numeric fields
|
||||
if !is_text_specifier(specifier) && result.len() >= 2 {
|
||||
if pad_char == ' ' && result.starts_with('0') {
|
||||
// Switching to space padding: strip leading zeros
|
||||
result = strip_default_padding(&result);
|
||||
} else if pad_char == '0' && result.starts_with(' ') {
|
||||
// Switching to zero padding: strip leading spaces
|
||||
result = strip_default_padding(&result);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply force sign for numeric values
|
||||
// GNU behavior: + only adds sign if:
|
||||
// 1. An explicit width is provided, OR
|
||||
// 2. The value exceeds the default width for that specifier (e.g., year > 4 digits)
|
||||
if force_sign && !result.starts_with('+') && !result.starts_with('-') {
|
||||
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
result.insert(0, '+');
|
||||
let default_w = get_default_width(specifier);
|
||||
// Add sign only if explicit width provided OR result exceeds default width
|
||||
if explicit_width || (default_w > 0 && result.len() > default_w) {
|
||||
result.insert(0, '+');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply width padding
|
||||
if width > result.len() {
|
||||
let padding = width - result.len();
|
||||
if effective_width > result.len() {
|
||||
let padding = effective_width - result.len();
|
||||
let has_sign = result.starts_with('+') || result.starts_with('-');
|
||||
|
||||
if pad_char == '0' && has_sign {
|
||||
@@ -488,82 +574,140 @@ mod tests {
|
||||
#[test]
|
||||
fn test_apply_modifiers_basic() {
|
||||
// No modifiers (numeric specifier)
|
||||
assert_eq!(apply_modifiers("1999", "", 0, "Y"), "1999");
|
||||
assert_eq!(apply_modifiers("1999", "", 0, "Y", false), "1999");
|
||||
// Zero padding
|
||||
assert_eq!(apply_modifiers("1999", "0", 10, "Y"), "0000001999");
|
||||
assert_eq!(apply_modifiers("1999", "0", 10, "Y", true), "0000001999");
|
||||
// Space padding (strips leading zeros)
|
||||
assert_eq!(apply_modifiers("06", "_", 5, "m"), " 6");
|
||||
assert_eq!(apply_modifiers("06", "_", 5, "m", true), " 6");
|
||||
// No-pad (strips leading zeros, width ignored)
|
||||
assert_eq!(apply_modifiers("01", "-", 5, "d"), "1");
|
||||
assert_eq!(apply_modifiers("01", "-", 5, "d", true), "1");
|
||||
// Uppercase
|
||||
assert_eq!(apply_modifiers("june", "^", 0, "B"), "JUNE");
|
||||
assert_eq!(apply_modifiers("june", "^", 0, "B", false), "JUNE");
|
||||
// Swap case: all uppercase → lowercase
|
||||
assert_eq!(apply_modifiers("UTC", "#", 0, "Z"), "utc");
|
||||
assert_eq!(apply_modifiers("UTC", "#", 0, "Z", false), "utc");
|
||||
// Swap case: mixed case → uppercase
|
||||
assert_eq!(apply_modifiers("June", "#", 0, "B"), "JUNE");
|
||||
assert_eq!(apply_modifiers("June", "#", 0, "B", false), "JUNE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_modifiers_signs() {
|
||||
// Force sign
|
||||
assert_eq!(apply_modifiers("1970", "+", 6, "Y"), "+01970");
|
||||
// Force sign with explicit width
|
||||
assert_eq!(apply_modifiers("1970", "+", 6, "Y", true), "+01970");
|
||||
// Force sign without explicit width: should NOT add sign for 4-digit year
|
||||
assert_eq!(apply_modifiers("1999", "+", 0, "Y", false), "1999");
|
||||
// Force sign without explicit width: SHOULD add sign for year > 4 digits
|
||||
assert_eq!(apply_modifiers("12345", "+", 0, "Y", false), "+12345");
|
||||
// Negative with zero padding: sign first, then zeros
|
||||
assert_eq!(apply_modifiers("-22", "0", 5, "s"), "-0022");
|
||||
assert_eq!(apply_modifiers("-22", "0", 5, "s", true), "-0022");
|
||||
// Negative with space padding: spaces first, then sign
|
||||
assert_eq!(apply_modifiers("-22", "_", 5, "s"), " -22");
|
||||
assert_eq!(apply_modifiers("-22", "_", 5, "s", true), " -22");
|
||||
// Force sign (_+): + is last, overrides _ → zero pad with sign
|
||||
assert_eq!(apply_modifiers("5", "_+", 5, "s"), "+0005");
|
||||
assert_eq!(apply_modifiers("5", "_+", 5, "s", true), "+0005");
|
||||
// No-pad + uppercase: no padding applied
|
||||
assert_eq!(apply_modifiers("june", "-^", 10, "B"), "JUNE");
|
||||
assert_eq!(apply_modifiers("june", "-^", 10, "B", true), "JUNE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_flag_precedence() {
|
||||
// Test that ^ (uppercase) overrides # (swap case)
|
||||
assert_eq!(apply_modifiers("June", "^#", 0, "B"), "JUNE");
|
||||
assert_eq!(apply_modifiers("June", "#^", 0, "B"), "JUNE");
|
||||
assert_eq!(apply_modifiers("June", "^#", 0, "B", false), "JUNE");
|
||||
assert_eq!(apply_modifiers("June", "#^", 0, "B", false), "JUNE");
|
||||
// Test # alone (swap case)
|
||||
assert_eq!(apply_modifiers("June", "#", 0, "B"), "JUNE");
|
||||
assert_eq!(apply_modifiers("JUNE", "#", 0, "B"), "june");
|
||||
assert_eq!(apply_modifiers("June", "#", 0, "B", false), "JUNE");
|
||||
assert_eq!(apply_modifiers("JUNE", "#", 0, "B", false), "june");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_modifiers_text_specifiers() {
|
||||
// Text specifiers default to space padding
|
||||
assert_eq!(apply_modifiers("June", "", 10, "B"), " June");
|
||||
assert_eq!(apply_modifiers("Mon", "", 10, "a"), " Mon");
|
||||
assert_eq!(apply_modifiers("June", "", 10, "B", true), " June");
|
||||
assert_eq!(apply_modifiers("Mon", "", 10, "a", true), " Mon");
|
||||
// Numeric specifiers default to zero padding
|
||||
assert_eq!(apply_modifiers("6", "", 10, "m"), "0000000006");
|
||||
assert_eq!(apply_modifiers("6", "", 10, "m", true), "0000000006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_modifiers_width_smaller_than_result() {
|
||||
// Width smaller than result strips default padding
|
||||
assert_eq!(apply_modifiers("01", "", 1, "d"), "1");
|
||||
assert_eq!(apply_modifiers("06", "", 1, "m"), "6");
|
||||
assert_eq!(apply_modifiers("01", "", 1, "d", true), "1");
|
||||
assert_eq!(apply_modifiers("06", "", 1, "m", true), "6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_modifiers_parametrized() {
|
||||
let test_cases = vec![
|
||||
("1", "0", 3, "Y", "001"),
|
||||
("1", "_", 3, "d", " 1"),
|
||||
("1", "-", 3, "d", "1"), // no-pad: width ignored
|
||||
("abc", "^", 5, "B", " ABC"), // text specifier: space pad
|
||||
("5", "+", 4, "s", "+005"),
|
||||
("5", "_+", 4, "s", "+005"), // + is last: zero pad with sign
|
||||
("-3", "0", 5, "s", "-0003"),
|
||||
("05", "_", 3, "d", " 5"),
|
||||
("09", "-", 4, "d", "9"), // no-pad: width ignored
|
||||
("1970", "_+", 6, "Y", "+01970"), // + is last: zero pad with sign
|
||||
("1", "0", 3, "Y", true, "001"),
|
||||
("1", "_", 3, "d", true, " 1"),
|
||||
("1", "-", 3, "d", true, "1"), // no-pad: width ignored
|
||||
("abc", "^", 5, "B", true, " ABC"), // text specifier: space pad
|
||||
("5", "+", 4, "s", true, "+005"),
|
||||
("5", "_+", 4, "s", true, "+005"), // + is last: zero pad with sign
|
||||
("-3", "0", 5, "s", true, "-0003"),
|
||||
("05", "_", 3, "d", true, " 5"),
|
||||
("09", "-", 4, "d", true, "9"), // no-pad: width ignored
|
||||
("1970", "_+", 6, "Y", true, "+01970"), // + is last: zero pad with sign
|
||||
];
|
||||
|
||||
for (value, flags, width, spec, expected) in test_cases {
|
||||
for (value, flags, width, spec, explicit_width, expected) in test_cases {
|
||||
assert_eq!(
|
||||
apply_modifiers(value, flags, width, spec),
|
||||
apply_modifiers(value, flags, width, spec, explicit_width),
|
||||
expected,
|
||||
"value='{value}', flags='{flags}', width={width}, spec='{spec}'",
|
||||
"value='{value}', flags='{flags}', width={width}, spec='{spec}', explicit_width={explicit_width}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_underscore_flag_without_width() {
|
||||
// %_m should pad month to default width 2 with spaces
|
||||
assert_eq!(apply_modifiers("6", "_", 0, "m", false), " 6");
|
||||
// %_d should pad day to default width 2 with spaces
|
||||
assert_eq!(apply_modifiers("1", "_", 0, "d", false), " 1");
|
||||
// %_H should pad hour to default width 2 with spaces
|
||||
assert_eq!(apply_modifiers("5", "_", 0, "H", false), " 5");
|
||||
// %_Y should pad year to default width 4 with spaces
|
||||
assert_eq!(apply_modifiers("1999", "_", 0, "Y", false), "1999"); // already at default width
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plus_flag_without_width() {
|
||||
// %+Y without width should NOT add sign for 4-digit year
|
||||
assert_eq!(apply_modifiers("1999", "+", 0, "Y", false), "1999");
|
||||
// %+Y without width SHOULD add sign for year > 4 digits
|
||||
assert_eq!(apply_modifiers("12345", "+", 0, "Y", false), "+12345");
|
||||
// %+Y with explicit width should add sign
|
||||
assert_eq!(apply_modifiers("1999", "+", 6, "Y", true), "+01999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_flag_on_space_padded_specifiers() {
|
||||
// GNU date: %0e should override space-padding with zero-padding
|
||||
// Verified: `date -d "2024-06-05" "+%0e"` → "05"
|
||||
let date = make_test_date(1999, 6, 5, 5);
|
||||
let config = get_config();
|
||||
|
||||
// %0e: day-of-month (normally space-padded) with 0 flag → zero-padded
|
||||
let result = format_with_modifiers(&date, "%0e", &config).unwrap();
|
||||
assert_eq!(result, "05", "GNU: %0e should produce '05', not ' 5'");
|
||||
|
||||
// %0k: hour (normally space-padded) with 0 flag → zero-padded
|
||||
let result = format_with_modifiers(&date, "%0k", &config).unwrap();
|
||||
assert_eq!(result, "05", "GNU: %0k should produce '05', not ' 5'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_underscore_century_default_width() {
|
||||
// GNU date: %C default width is 2, not 4
|
||||
// Verified: `date -d "2024-06-15" "+%_C"` → "20" (no extra padding)
|
||||
let date = make_test_date(1999, 6, 1, 0);
|
||||
let config = get_config();
|
||||
|
||||
// %_C: century with underscore flag, no explicit width
|
||||
// Default width for %C should be 2 (century is 00-99)
|
||||
let result = format_with_modifiers(&date, "%_C", &config).unwrap();
|
||||
assert_eq!(
|
||||
result, "19",
|
||||
"GNU: %_C should produce '19', not ' 19' (default width is 2, not 4)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2414,6 +2414,126 @@ fn test_date_format_modifier_percent_escape() {
|
||||
.stdout_is("%Y=0000001999\n");
|
||||
}
|
||||
|
||||
// Tests for format modifier edge cases (flags without explicit width)
|
||||
#[test]
|
||||
fn test_date_format_modifier_edge_cases() {
|
||||
// Test cases: (date, format, expected_output, description)
|
||||
let cases = vec![
|
||||
// Underscore flag without explicit width (uses default width)
|
||||
("1999-06-01", "%_d", " 1", "%_d pads day to default width 2"),
|
||||
(
|
||||
"1999-06-15",
|
||||
"%_m",
|
||||
" 6",
|
||||
"%_m pads month to default width 2",
|
||||
),
|
||||
(
|
||||
"1999-06-01 05:00:00",
|
||||
"%_H",
|
||||
" 5",
|
||||
"%_H pads hour to default width 2",
|
||||
),
|
||||
(
|
||||
"1999-06-01",
|
||||
"%_Y",
|
||||
"1999",
|
||||
"%_Y year already at default width 4",
|
||||
),
|
||||
(
|
||||
"1999-06-01",
|
||||
"%_C",
|
||||
"19",
|
||||
"%_C century uses default width 2",
|
||||
),
|
||||
("2024-06-01", "%_C", "20", "%_C century for year 2024"),
|
||||
(
|
||||
"1999-01-01",
|
||||
"%_j",
|
||||
" 1",
|
||||
"%_j pads day-of-year to default width 3",
|
||||
),
|
||||
("1999-04-10", "%_j", "100", "%_j day 100 already at width 3"),
|
||||
// Zero flag on space-padded specifiers (overrides default padding)
|
||||
(
|
||||
"1999-06-05",
|
||||
"%0e",
|
||||
"05",
|
||||
"%0e overrides space-padding with zero",
|
||||
),
|
||||
(
|
||||
"1999-06-01 05:00:00",
|
||||
"%0k",
|
||||
"05",
|
||||
"%0k overrides space-padding with zero",
|
||||
),
|
||||
(
|
||||
"1999-06-01 05:00:00",
|
||||
"%0l",
|
||||
"05",
|
||||
"%0l overrides space-padding with zero",
|
||||
),
|
||||
// Zero flag without explicit width (uses default width)
|
||||
(
|
||||
"1999-06-01",
|
||||
"%0d",
|
||||
"01",
|
||||
"%0d day with zero padding (default width 2)",
|
||||
),
|
||||
(
|
||||
"1999-06-15",
|
||||
"%0m",
|
||||
"06",
|
||||
"%0m month with zero padding (default width 2)",
|
||||
),
|
||||
(
|
||||
"1999-01-01",
|
||||
"%0j",
|
||||
"001",
|
||||
"%0j day-of-year with zero padding (default width 3)",
|
||||
),
|
||||
// Space-padded specifiers default behavior (no modifier)
|
||||
("1999-06-05", "%e", " 5", "%e defaults to space padding"),
|
||||
(
|
||||
"1999-06-01 05:00:00",
|
||||
"%k",
|
||||
" 5",
|
||||
"%k defaults to space padding",
|
||||
),
|
||||
(
|
||||
"1999-06-01 05:00:00",
|
||||
"%l",
|
||||
" 5",
|
||||
"%l defaults to space padding",
|
||||
),
|
||||
// Plus flag without explicit width
|
||||
(
|
||||
"1999-06-01",
|
||||
"%+Y",
|
||||
"1999",
|
||||
"%+Y no sign for 4-digit year without width",
|
||||
),
|
||||
(
|
||||
"1999-06-01",
|
||||
"%+6Y",
|
||||
"+01999",
|
||||
"%+6Y with explicit width adds sign",
|
||||
),
|
||||
];
|
||||
|
||||
for (date, format, expected, description) in cases {
|
||||
let result = new_ucmd!()
|
||||
.env("TZ", "UTC")
|
||||
.args(&["-d", date, &format!("+{format}")])
|
||||
.succeeds();
|
||||
// stdout includes newline, expected is without newline
|
||||
assert_eq!(
|
||||
result.stdout_str(),
|
||||
format!("{expected}\n"),
|
||||
"{description}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for --debug flag
|
||||
#[test]
|
||||
fn test_date_debug_basic() {
|
||||
|
||||
Reference in New Issue
Block a user