diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index d95a71b7c9f..8ba5d24d7cc 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -802,6 +802,44 @@ pub fn uu_app() -> Command { ) } +/// Expand `%c` into the locale's date and time format. +/// +/// jiff renders `%c` with the POSIX format, but GNU `date` stands it for the +/// locale's `D_T_FMT`: that is what makes `%c` carry the timezone under +/// `en_US` and drop it under `fr_FR`. Rewriting the format string rather than +/// rendering `%c` ourselves keeps the expansion on the normal path, so the +/// month and day names inside it are still localized afterwards. +fn substitute_datetime_format(fmt: &str) -> Cow<'_, str> { + if !fmt.contains("%c") { + return Cow::Borrowed(fmt); + } + let Some(datetime_format) = locale::get_locale_datetime_format() else { + return Cow::Borrowed(fmt); + }; + + let mut out = String::with_capacity(fmt.len()); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.peek() { + Some('c') => { + chars.next(); + out.push_str(datetime_format); + } + // Keep `%%` intact so jiff still renders it as a literal percent. + Some('%') => { + chars.next(); + out.push_str("%%"); + } + _ => out.push('%'), + } + } + Cow::Owned(out) +} + /// Replace bare `%s` conversion specifiers in `fmt` with the Unix epoch second /// using floor semantics. /// @@ -1037,6 +1075,12 @@ fn format_date_with_locale_aware_months( #[cfg(feature = "i18n-datetime")] skip_localization: bool, #[cfg(not(feature = "i18n-datetime"))] _skip_localization: bool, ) -> Result { + // `%c` stands for a whole format string, so expand it before anything else + // reads the format: the names it contains still have to be localized, and + // its own specifiers still have to reach jiff. + let expanded = substitute_datetime_format(format_string); + let format_string: &str = &expanded; + // Apply locale-aware name substitution (month/day names) before modifier // processing, so that formats like "%-e" don't bypass localization of "%b"/"%A". // The owned String is kept in `localized` so `fmt` can borrow from it for the diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index ce2c05406b0..6b679c94a99 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -46,6 +46,9 @@ cfg_langinfo! { /// Cached locale date/time format string static DEFAULT_FORMAT_CACHE: OnceLock<&'static [u8]> = OnceLock::new(); + /// Cached locale `D_T_FMT`, the format `%c` stands for. + static DATETIME_FORMAT_CACHE: OnceLock> = OnceLock::new(); + /// Mutex to serialize setlocale() calls during tests. /// /// setlocale() is process-global, so parallel tests that call it can @@ -73,6 +76,26 @@ cfg_langinfo! { /// Retrieves the date/time format string from the system locale fn get_locale_format_string() -> Option> { + query_nl_langinfo(DATE_FMT) + } + + /// Returns the locale's combined date and time format (`D_T_FMT`), which + /// is what `%c` stands for. + /// + /// `None` when the locale supplies no format, or when that format is not + /// valid UTF-8, as happens with legacy charsets such as `zh_TW.euctw`. + /// Callers then leave `%c` to its POSIX rendering. + pub fn get_locale_datetime_format() -> Option<&'static str> { + *DATETIME_FORMAT_CACHE.get_or_init(|| { + let format = String::from_utf8(query_nl_langinfo(libc::D_T_FMT)?).ok()?; + Some(&*Box::leak(format.into_boxed_str())) + }) + } + + /// Reads one `nl_langinfo` item for the locale named by the environment. + /// + /// Returns bytes because legacy charsets (e.g. `zh_TW.euctw`) are not UTF-8. + fn query_nl_langinfo(item: libc::nl_item) -> Option> { // In tests, acquire mutex to prevent race conditions with setlocale() // which is process-global and not thread-safe #[cfg(test)] @@ -82,14 +105,13 @@ cfg_langinfo! { // Set locale from environment variables libc::setlocale(libc::LC_TIME, c"".as_ptr()); - // Get the date/time format string - let d_t_fmt_ptr = libc::nl_langinfo(DATE_FMT); - if d_t_fmt_ptr.is_null() { + let item_ptr = libc::nl_langinfo(item); + if item_ptr.is_null() { return None; } - let format = CStr::from_ptr(d_t_fmt_ptr).to_bytes(); - (!format.is_empty()).then(|| format.to_vec()) + let value = CStr::from_ptr(item_ptr).to_bytes(); + (!value.is_empty()).then(|| value.to_vec()) } } } @@ -99,6 +121,15 @@ cfg_langinfo! { else pub fn get_locale_default_format() -> &'static [u8] { POSIX_DEFAULT_FORMAT } + + /// Without a langinfo query, `%c` keeps the POSIX rendering jiff gives it. + /// + /// `D_T_FMT` is POSIX, unlike the `_DATE_FMT` extension above, so the + /// other Unix platforms could answer this too; wiring them up needs a + /// machine to check them on. + pub fn get_locale_datetime_format() -> Option<&'static str> { + None + } } #[cfg(test)] diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 3b4be7fd9e8..8b2ef4cac0d 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1675,6 +1675,74 @@ fn test_date_military_timezone_with_offset_and_date() { } } +/// The `%c` specifier stands for the locale's `D_T_FMT`, so its rendering has +/// to match what that very format string produces. Deriving the expectation +/// from the locale keeps the test independent of the distribution's locale +/// data, and it is exactly the property that was broken: `%c` used to fall +/// back to the POSIX format whatever the locale said. +#[test] +#[cfg(unix)] +fn test_date_c_specifier_uses_locale_datetime_format() { + let locale = "en_US.UTF-8"; + if !is_locale_available(locale) { + return; + } + + let Some(d_t_fmt) = locale_keyword(locale, "d_t_fmt") else { + return; + }; + + let render = |format: &str| { + new_ucmd!() + .env("LC_ALL", locale) + .env("TZ", "UTC") + .args(&["-d", "2023-11-14T23:13:20", &format!("+{format}")]) + .succeeds() + .stdout_move_str() + }; + + assert_eq!(render("%c"), render(&d_t_fmt)); +} + +/// In the C locale `D_T_FMT` is the POSIX format, so `%c` keeps its plain +/// rendering. This one needs no locale to be installed. +#[test] +fn test_date_c_specifier_in_c_locale() { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&["-d", "2023-11-14T23:13:20", "+%c"]) + .succeeds() + .stdout_only("Tue Nov 14 23:13:20 2023\n"); +} + +/// Expanding `%c` must not swallow an escaped percent sign. +#[test] +fn test_date_escaped_percent_before_c() { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&["-d", "2023-11-14T23:13:20", "+%%c"]) + .succeeds() + .stdout_only("%c\n"); +} + +/// Reads one keyword out of `locale(1)`, e.g. `d_t_fmt`. `None` when the +/// command is missing or answers nothing usable. +#[cfg(unix)] +fn locale_keyword(locale: &str, keyword: &str) -> Option { + let output = std::process::Command::new("locale") + .env("LC_ALL", locale) + .arg(keyword) + .output() + .ok()?; + let value = String::from_utf8(output.stdout) + .ok()? + .trim_end() + .to_string(); + (!value.is_empty()).then_some(value) +} + // Locale-aware hour formatting tests #[test] #[cfg(unix)]