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
44 changes: 44 additions & 0 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<String, String> {
// `%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
Expand Down
41 changes: 36 additions & 5 deletions src/uu/date/src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<&'static str>> = OnceLock::new();

/// Mutex to serialize setlocale() calls during tests.
///
/// setlocale() is process-global, so parallel tests that call it can
Expand Down Expand Up @@ -73,6 +76,26 @@ cfg_langinfo! {

/// Retrieves the date/time format string from the system locale
fn get_locale_format_string() -> Option<Vec<u8>> {
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<Vec<u8>> {
// In tests, acquire mutex to prevent race conditions with setlocale()
// which is process-global and not thread-safe
#[cfg(test)]
Expand All @@ -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())
}
}
}
Expand All @@ -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)]
Expand Down
68 changes: 68 additions & 0 deletions tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I work on it when I come back from school.

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<String> {
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)]
Expand Down
Loading