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
38 changes: 33 additions & 5 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@

/// OHOS helper: pass through the system time zone ID returned by
/// TimeService (OH_TimeService_GetTimeZone, e.g. "Asia/Shanghai") and
/// resolve it against the embedded IANA tzdata (jiff-tzdb) so that

Check warning on line 38 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 'tzdb' (file:'src/uu/date/src/date.rs', line:38)
/// historial DST rules and transitions are preserved. jiff's

Check warning on line 39 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 'historial' (file:'src/uu/date/src/date.rs', line:39)
/// `try_system()` is useless on OHOS because both `/etc/localtime` and
/// the zoneinfo dirs are absent.

Check warning on line 41 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 'zoneinfo' (file:'src/uu/date/src/date.rs', line:41)
#[cfg(target_env = "ohos")]
fn ohos_system_zone() -> jiff::tz::TimeZone {
use core::ffi::{CStr, c_char};
Expand All @@ -55,7 +55,7 @@
let id = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }
.to_string_lossy()
.into_owned();
if let Some((name, tzif)) = jiff_tzdb::get(&id) {

Check warning on line 58 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 'tzif' (file:'src/uu/date/src/date.rs', line:58)
if let Ok(tz) = jiff::tz::TimeZone::tzif(name, tzif) {
return tz;
}
Expand Down Expand Up @@ -837,10 +837,16 @@
/// 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();
}
Expand All @@ -856,7 +862,10 @@
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('%');
Expand Down Expand Up @@ -966,7 +975,12 @@
// 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
Expand Down Expand Up @@ -1372,6 +1386,20 @@
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
Expand Down
174 changes: 174 additions & 0 deletions src/uu/date/src/format_modifiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need 20 lines of comments that nobody will read? :)

///
/// 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<String, FormatError> {
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<String, FormatError> {
// Last pad-affecting flag wins: `-` suppresses padding, `_` selects
// spaces, `0`/`+` select zeros; the default is space padding.
let mut pad: Option<char> = 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?
///
Expand Down Expand Up @@ -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"
);
}
}
28 changes: 28 additions & 0 deletions tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading