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
3 changes: 2 additions & 1 deletion src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,12 +395,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
};

let format_string = make_format_string(&settings);
let format_string = locale::expand_locale_format(format_string);

// Format all the dates
for date in dates {
match date {
// TODO: Switch to lenient formatting.
Ok(date) => match strtime::format(format_string, &date) {
Ok(date) => match strtime::format(format_string.as_ref(), &date) {
Ok(s) => println!("{s}"),
Err(e) => {
return Err(USimpleError::new(
Expand Down
161 changes: 156 additions & 5 deletions src/uu/date/src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,103 @@ cfg_langinfo! {
})
}

/// Retrieves the date/time format string from the system locale
fn get_locale_format_string() -> Option<String> {
/// Replaces %c, %x, %X with their locale-specific format strings.
///
/// If a flag like `^` is present (e.g., `%^c`), it is distributed to the
/// sub-specifiers within the locale string.
pub fn expand_locale_format(format: &str) -> std::borrow::Cow<'_, str> {
let mut result = String::with_capacity(format.len());
let mut chars = format.chars().peekable();
let mut modified = false;

while let Some(c) = chars.next() {
if c != '%' {
result.push(c);
continue;
}

// Capture flags
let mut flags = Vec::new();
while let Some(&peek) = chars.peek() {
match peek {
'_' | '-' | '0' | '^' | '#' => {
flags.push(peek);
chars.next();
},
_ => break,
}
}

match chars.peek() {
Some(&spec @ ('c' | 'x' | 'X')) => {
chars.next();

let item = match spec {
'c' => libc::D_T_FMT,
'x' => libc::D_FMT,
'X' => libc::T_FMT,
_ => unreachable!(),
};

if let Some(s) = get_langinfo(item) {
// If the user requested uppercase (%^c), distribute that flag
// to the expanded specifiers.
let replacement = if flags.contains(&'^') {
distribute_flag(&s, '^')
} else {
s
};
result.push_str(&replacement);
modified = true;
} else {
// Reconstruct original sequence if lookup fails
result.push('%');
result.extend(flags);
result.push(spec);
}
},
Some(_) | None => {
// Not a locale specifier, or end of string.
// Push captured flags and let loop handle the next char.
result.push('%');
result.extend(flags);
}
}
}

if modified {
std::borrow::Cow::Owned(result)
} else {
std::borrow::Cow::Borrowed(format)
}
}

/// Distributes the given flag to all format specifiers in the string.
///
/// For example, if the format string is "%a %b" and the flag is '^',
/// the result will be "%^a %^b". Literal percent signs ("%%") are skipped
/// and left as is.
fn distribute_flag(fmt: &str, flag: char) -> String {
let mut res = String::with_capacity(fmt.len() * 2);
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
res.push(c);
if c == '%' {
if let Some(&n) = chars.peek() {
if n == '%' {
chars.next();
res.push('%');
} else {
res.push(flag);
}
}
}
}
res
}

/// Retrieves the date/time format string from the system locale (D_T_FMT, D_FMT, T_FMT)
pub fn get_langinfo(item: libc::nl_item) -> Option<String> {
// In tests, acquire mutex to prevent race conditions with setlocale()
// which is process-global and not thread-safe
#[cfg(test)]
Expand All @@ -76,12 +171,12 @@ cfg_langinfo! {
libc::setlocale(libc::LC_TIME, c"".as_ptr());

// Get the date/time format string
let d_t_fmt_ptr = libc::nl_langinfo(libc::D_T_FMT);
if d_t_fmt_ptr.is_null() {
let fmt_ptr = libc::nl_langinfo(item);
if fmt_ptr.is_null() {
return None;
}

let format = CStr::from_ptr(d_t_fmt_ptr).to_str().ok()?;
let format = CStr::from_ptr(fmt_ptr).to_str().ok()?;
if format.is_empty() {
return None;
}
Expand All @@ -90,6 +185,11 @@ cfg_langinfo! {
}
}

/// Retrieves the date/time format string from the system locale
fn get_locale_format_string() -> Option<String> {
get_langinfo(libc::D_T_FMT)
}

/// Ensures the format string includes timezone (%Z)
fn ensure_timezone_in_format(format: &str) -> String {
if format.contains("%Z") {
Expand Down Expand Up @@ -123,6 +223,18 @@ pub fn get_locale_default_format() -> &'static str {
"%a %b %e %X %Z %Y"
}

#[cfg(not(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
)))]
pub fn expand_locale_format(format: &str) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(format)
}

#[cfg(test)]
mod tests {
cfg_langinfo! {
Expand Down Expand Up @@ -260,4 +372,43 @@ mod tests {
);
}
}

#[test]
#[cfg(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
))]
fn test_distribute_flag() {
// Standard distribution
assert_eq!(distribute_flag("%a %b", '^'), "%^a %^b");
// Ignore literals
assert_eq!(distribute_flag("foo %a bar", '_'), "foo %_a bar");
// Skip escaped percent signs
assert_eq!(distribute_flag("%% %a", '^'), "%% %^a");
// Handle flags that might already exist
assert_eq!(distribute_flag("%_a", '^'), "%^_a");
}

#[test]
#[cfg(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
))]
fn test_expand_locale_format_basic() {
let format = "%^x";
let expanded = expand_locale_format(format).to_string().to_lowercase();

assert_ne!(expanded, "%^x", "Should have expanded %^x");
assert!(expanded.contains("%^d"), "Should contain %^d");
assert!(expanded.contains("%^m"), "Should contain %^m");
assert!(expanded.contains("%^y"), "Should contain %^y");
}
}
52 changes: 52 additions & 0 deletions tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1404,3 +1404,55 @@ fn test_date_locale_fr_french() {
"Output should include timezone information, got: {stdout}"
);
}

#[test]
#[cfg(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
))]
fn test_format_upper_c_locale_expansion() {
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC")
.arg("-d")
.arg("2024-01-01")
.arg("+%^c")
.succeeds()
.stdout_str_check(|out| {
// In C locale, %c expands to "%a %b %e %H:%M:%S %Y" (e.g., "Mon Jan 1 ...")
// With the ^ flag, we expect "MON JAN 1 ..."
out.contains("MON") && out.contains("JAN")
});
}

#[test]
#[cfg(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
))]
fn test_format_upper_c_locale_expansion_french() {
let result = new_ucmd!()
.env("LC_ALL", "fr_FR.UTF-8")
.arg("-d")
.arg("2024-01-01T13:00:00")
.arg("+%^c")
.run();

let stdout = result.stdout_str();

// Should have 13:00 (not 1:00)
assert!(
stdout.contains("13:00"),
"French locale should show 13:00 for 1 PM, got: {stdout}"
);

assert_eq!(stdout.to_uppercase(), stdout, "Output should be uppercase");
}
Loading