diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 455e72d6e09..7bcdb3db97d 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -837,10 +837,16 @@ fn substitute_epoch_seconds(fmt: &str, date: &Zoned) -> String { /// literally, so strip it before jiff sees the format string. /// /// The `O` is only dropped when it actually modifies something, that is, -/// when a specifier letter follows it. A dangling `%O` (at the end of the -/// string, or followed by a non-letter) stays literal, as does any `O` -/// that merely follows the `%%` escape. +/// when a specifier letter GNU knows follows it. `%Of`/`%OQ` address +/// conversions GNU itself leaves literal, so the `O` is kept there (jiff +/// then emits the whole sequence literally, matching GNU). A dangling `%O` +/// (at the end of the string, or followed by a non-letter) stays literal, +/// as does any `O` that merely follows the `%%` escape. fn strip_o_modifier(fmt: &str) -> String { + // Conversion letters GNU does not implement: `%O` cannot modify them, + // the whole sequence stays literal. + const GNU_UNKNOWN: &[char] = &['f', 'Q']; + if !fmt.contains("%O") { return fmt.to_string(); } @@ -856,7 +862,10 @@ fn strip_o_modifier(fmt: &str) -> String { Some('O') => { let mut lookahead = chars.clone(); lookahead.next(); - if lookahead.peek().is_some_and(char::is_ascii_alphabetic) { + if lookahead + .peek() + .is_some_and(|c| c.is_ascii_alphabetic() && !GNU_UNKNOWN.contains(c)) + { chars.next(); } out.push('%'); @@ -966,7 +975,12 @@ fn format_date_with_locale_aware_months( // negative infinity (e.g. `@-1.5` → `-2`, not `-1`). Every other field jiff // produces already agrees with GNU, so only `%s` needs correcting; rewrite it // to the floored epoch second before jiff sees the format string. - let fmt_owned = strip_o_modifier(&substitute_epoch_seconds(fmt, date)); + // Likewise, jiff implements `%f`/`%Q` while GNU leaves them literal, so + // escape those before jiff (or the modifier path below) sees the string. + let fmt_owned = format_modifiers::escape_jiff_only_specifiers(&strip_o_modifier( + &substitute_epoch_seconds(fmt, date), + )) + .map_err(|e| e.to_string())?; let fmt = fmt_owned.as_str(); // Check if format string has GNU modifiers (width/flags) and format if present @@ -1372,6 +1386,20 @@ fn set_system_datetime(date: Zoned) -> UResult<()> { mod tests { use super::*; + #[test] + fn test_strip_o_modifier() { + // `O` modifying a GNU-known conversion is a no-op: strip it. + assert_eq!(strip_o_modifier("%Om"), "%m"); + assert_eq!(strip_o_modifier("%Od %H:%M"), "%d %H:%M"); + // `f`/`Q` are unknown to GNU, so `%O` cannot modify them: keep the + // sequence literal (jiff then renders it literally, like GNU). + assert_eq!(strip_o_modifier("%Of"), "%Of"); + assert_eq!(strip_o_modifier("%OQ"), "%OQ"); + // Dangling `%O` and `%%O` stay literal. + assert_eq!(strip_o_modifier("%O"), "%O"); + assert_eq!(strip_o_modifier("%%Om"), "%%Om"); + } + #[test] fn test_parse_military_timezone_with_offset() { // Valid cases: letter only, letter + digit, uppercase diff --git a/src/uu/date/src/format_modifiers.rs b/src/uu/date/src/format_modifiers.rs index 1ad1fcc6bab..af3fab9c296 100644 --- a/src/uu/date/src/format_modifiers.rs +++ b/src/uu/date/src/format_modifiers.rs @@ -164,6 +164,110 @@ pub fn format_with_modifiers_if_present( Some(format_with_modifiers(date, format_string, config)) } +/// Escape conversion specifiers that jiff renders but GNU `date` leaves literal. +/// +/// jiff implements a few strftime extensions unknown to GNU, notably `%f` +/// (fractional seconds) and `%Q` (timezone abbreviation). Without this +/// rewrite, `date +%f` prints the nanoseconds instead of `%f` (issue #14600). +/// `%%` escapes are preserved and every other specifier is left untouched +/// for jiff to render, so this must run before jiff (or the modifier path +/// above) sees the format string. +/// +/// Each `%[flags][width][:]{0,3}[fQ]` is rewritten to a `%%`-escaped literal +/// reproducing GNU's output for unknown conversions: space padding by +/// default, with `0` and `+` selecting zero padding and `_` selecting space +/// padding (last flag wins), `-` suppressing padding, and `^` forcing the +/// conversion letter to uppercase. (` ` is not a flag in the grammar below, so +/// space-flag forms pass through to jiff, which renders them literally +/// just like GNU.) +/// +/// # Errors +/// +/// Returns `FieldWidthTooLarge` if a padded width exceeds `MAX_FORMAT_WIDTH`, +/// exactly like the modifier path. +pub fn escape_jiff_only_specifiers(fmt: &str) -> Result { + if !fmt.contains('f') && !fmt.contains('Q') { + return Ok(fmt.to_string()); + } + + let bytes = fmt.as_bytes(); + let mut out = String::with_capacity(fmt.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'%' { + // Pass-through: copy a single UTF-8 code point unchanged. + let ch_len = fmt[i..].chars().next().map_or(1, char::len_utf8); + out.push_str(&fmt[i..i + ch_len]); + i += ch_len; + continue; + } + // Keep `%%` intact: a letter after a literal percent is plain text. + if bytes.get(i + 1) == Some(&b'%') { + out.push_str("%%"); + i += 2; + continue; + } + match parse_format_spec(&fmt[i..]) { + Some(parsed) if matches!(parsed.spec.chars().last(), Some('f' | 'Q')) => { + out.push_str(&gnu_unknown_literal(&fmt[i..i + parsed.len], &parsed)?); + i += parsed.len; + } + _ => { + out.push('%'); + i += 1; + } + } + } + Ok(out) +} + +/// Render GNU's output for one unknown `%[flags][width][:]{0,3}[fQ]` +/// conversion (`raw`) as a `%%`-escaped literal for jiff to copy through. +fn gnu_unknown_literal(raw: &str, parsed: &ParsedSpec<'_>) -> Result { + // Last pad-affecting flag wins: `-` suppresses padding, `_` selects + // spaces, `0`/`+` select zeros; the default is space padding. + let mut pad: Option = Some(' '); + for flag in parsed.flags.chars() { + match flag { + '-' => pad = None, + '_' => pad = Some(' '), + '0' | '+' => pad = Some('0'), + _ => {} + } + } + + let mut letter = parsed.spec.as_bytes().last().copied().unwrap_or(b'f'); + if parsed.flags.contains('^') { + letter = letter.to_ascii_uppercase(); + } + // Rebuild the literal tail byte-for-byte (`%`, flags, original width + // digits, colons, letter), e.g. `%010f` or `%::Q`. + let tail = format!( + "%{}{}{}", + parsed.flags, + &raw[1 + parsed.flags.len()..raw.len() - parsed.spec.len()], + &parsed.spec[..parsed.spec.len() - 1], + ); + let mut literal = String::with_capacity(tail.len() + 3); + literal.push_str(&tail); + literal.push(letter as char); + + if let (Some(pad), Some(width)) = (pad, parsed.width) { + if width > MAX_FORMAT_WIDTH { + return Err(field_width_too_large(width, parsed.spec)); + } + let missing = width.saturating_sub(literal.len()); + if missing > 0 { + let mut padded = String::with_capacity(literal.len() + missing); + padded.extend(std::iter::repeat_n(pad, missing)); + padded.push_str("%%"); + padded.push_str(&literal[1..]); + return Ok(padded); + } + } + Ok(format!("%%{}", &literal[1..])) +} + /// Quick check: does the format string contain any GNU modifier /// (a flag or width) on a `%`-spec, ignoring `%%` literals? /// @@ -1070,4 +1174,74 @@ mod tests { assert_eq!(has_gnu_modifiers(input), *expected, "input = {input:?}"); } } + + #[test] + fn test_escape_jiff_only_specifiers() { + // (input, expected rewrite); expected outputs match GNU `date`. + let cases: &[(&str, &str)] = &[ + // ---- bare specifiers ---- + ("%f", "%%f"), + ("%Q", "%%Q"), + // ---- everything else passes through untouched ---- + ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S"), + ("%q", "%q"), // lowercase quarter is known to GNU, not escaped + ("%%f", "%%f"), // literal percent: the `f` is plain text + ("%%Q", "%%Q"), + ("100%f", "100%%f"), + ("no percent here", "no percent here"), + ("", ""), + // ---- flags select padding, last one wins ---- + ("%-f", "%%-f"), + ("% f", "% f"), // ` ` is not a flag: passes through, jiff renders it literally + ("%_f", "%%_f"), + ("%#f", "%%#f"), + ("%+f", "%%+f"), + ("%0_10f", " %%0_10f"), + ("%_010f", "0000%%_010f"), + ("%-010f", "0000%%-010f"), + // ---- `^` forces the letter to uppercase ---- + ("%^f", "%%^F"), + ("%^Q", "%%^Q"), + ("%^10f", " %%^10F"), + // ---- width pads the literal ---- + ("%10f", " %%10f"), + ("%010f", "00000%%010f"), + ("%3f", "%%3f"), + ("%10Q", " %%10Q"), + // ---- colon variants are unknown to GNU too ---- + ("%:f", "%%:f"), + ("%::f", "%%::f"), + ("%:::Q", "%%:::Q"), + // ---- modifiers on other specs are not ours to touch ---- + ("%10Y", "%10Y"), + ("%Om", "%Om"), + ("%Of", "%Of"), // `O` is not a flag; strip_o_modifier owns this + // ---- mixed strings ---- + ("%Y-%m-%d %f", "%Y-%m-%d %%f"), + ("%%%f", "%%%%f"), + ("a%fb", "a%%fb"), + ]; + + for (input, expected) in cases { + assert_eq!( + escape_jiff_only_specifiers(input).unwrap(), + *expected, + "input = {input:?}" + ); + } + } + + #[test] + fn test_escape_jiff_only_specifiers_width_too_large() { + let err = escape_jiff_only_specifiers("%100000f").unwrap_err(); + assert!(matches!( + err, + FormatError::FieldWidthTooLarge { width: 100_000, .. } + )); + // No padding requested: huge widths stay literal, like GNU. + assert_eq!( + escape_jiff_only_specifiers("%-100000f").unwrap(), + "%%-100000f" + ); + } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 7f9e2c4eceb..4be42197ae7 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -510,6 +510,34 @@ fn test_date_format_literal() { new_ucmd!().arg("+%%N").succeeds().stdout_is("%N\n"); } +#[test] +fn test_date_format_unknown_specifiers_stay_literal() { + // `%f` and `%Q` are jiff extensions that GNU `date` does not know, so + // they must be printed literally instead of rendered (#14600). + new_ucmd!().arg("+%f").succeeds().stdout_is("%f\n"); + new_ucmd!().arg("+%Q").succeeds().stdout_is("%Q\n"); + new_ucmd!().arg("+%:f").succeeds().stdout_is("%:f\n"); + new_ucmd!() + .arg("+%Y-%m-%d %f") + .succeeds() + .stdout_matches(&Regex::new(r"^\d{4}-\d{2}-\d{2} %f\n$").unwrap()); + // GNU applies its width/flag quirks to the literal text. + new_ucmd!().arg("+%-f").succeeds().stdout_is("%-f\n"); + new_ucmd!() + .arg("+%10f") + .succeeds() + .stdout_is(" %10f\n"); + new_ucmd!() + .arg("+%010f") + .succeeds() + .stdout_is("00000%010f\n"); + new_ucmd!().arg("+%^f").succeeds().stdout_is("%^F\n"); + new_ucmd!().arg("+% f").succeeds().stdout_is("% f\n"); + new_ucmd!().arg("+%%f").succeeds().stdout_is("%f\n"); + // `%O` cannot modify conversions GNU does not know: keep it literal. + new_ucmd!().arg("+%Of").succeeds().stdout_is("%Of\n"); +} + #[test] #[cfg(all(unix, not(target_vendor = "apple")))] fn test_date_set_valid() {