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:
Cả thế giới là Rust
2026-02-27 09:13:59 +01:00
committed by GitHub
parent e939eda7bd
commit 2c3eab9440
2 changed files with 313 additions and 49 deletions
+193 -49
View File
@@ -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)"
);
}
}
+120
View File
@@ -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() {