Skip to content

Commit 230c85d

Browse files
committed
date: fix handling of case-change flags in locale format specifiers
1 parent 3162c21 commit 230c85d

File tree

3 files changed

+130
-6
lines changed

3 files changed

+130
-6
lines changed

src/uu/date/src/date.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,12 +395,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
395395
};
396396

397397
let format_string = make_format_string(&settings);
398+
let format_string = locale::expand_locale_format(format_string);
398399

399400
// Format all the dates
400401
for date in dates {
401402
match date {
402403
// TODO: Switch to lenient formatting.
403-
Ok(date) => match strtime::format(format_string, &date) {
404+
Ok(date) => match strtime::format(format_string.as_ref(), &date) {
404405
Ok(s) => println!("{s}"),
405406
Err(e) => {
406407
return Err(USimpleError::new(

src/uu/date/src/locale.rs

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,109 @@ cfg_langinfo! {
5353
})
5454
}
5555

56-
/// Retrieves the date/time format string from the system locale
57-
fn get_locale_format_string() -> Option<String> {
56+
/// Replaces %c, %x, %X with their locale-specific format strings.
57+
///
58+
/// If a flag like `^` is present (e.g., `%^c`), it is distributed to the
59+
/// sub-specifiers within the locale string.
60+
pub fn expand_locale_format(format: &str) -> std::borrow::Cow<str> {
61+
let mut result = String::with_capacity(format.len());
62+
let mut chars = format.chars().peekable();
63+
let mut modified = false;
64+
65+
while let Some(c) = chars.next() {
66+
if c != '%' {
67+
result.push(c);
68+
continue;
69+
}
70+
71+
// Capture flags
72+
let mut flags = Vec::new();
73+
while let Some(&peek) = chars.peek() {
74+
match peek {
75+
'_' | '-' | '0' | '^' | '#' => {
76+
flags.push(peek);
77+
chars.next();
78+
},
79+
_ => break,
80+
}
81+
}
82+
83+
match chars.peek() {
84+
Some(&spec @ ('c' | 'x' | 'X')) => {
85+
chars.next();
86+
87+
let item = match spec {
88+
'c' => libc::D_T_FMT,
89+
'x' => libc::D_FMT,
90+
'X' => libc::T_FMT,
91+
_ => unreachable!(),
92+
};
93+
94+
if let Some(s) = get_langinfo(item) {
95+
// If the user requested uppercase (%^c), distribute that flag
96+
// to the expanded specifiers.
97+
let replacement = if flags.contains(&'^') {
98+
distribute_flag(&s, '^')
99+
} else {
100+
s
101+
};
102+
result.push_str(&replacement);
103+
modified = true;
104+
} else {
105+
// Reconstruct original sequence if lookup fails
106+
result.push('%');
107+
result.extend(flags);
108+
result.push(spec);
109+
}
110+
},
111+
Some(_) | None => {
112+
// Not a locale specifier, or end of string.
113+
// Push captured flags and let loop handle the next char.
114+
result.push('%');
115+
result.extend(flags);
116+
}
117+
}
118+
}
119+
120+
if modified {
121+
std::borrow::Cow::Owned(result)
122+
} else {
123+
std::borrow::Cow::Borrowed(format)
124+
}
125+
}
126+
127+
fn distribute_flag(fmt: &str, flag: char) -> String {
128+
let mut res = String::with_capacity(fmt.len() * 2);
129+
let mut chars = fmt.chars().peekable();
130+
while let Some(c) = chars.next() {
131+
res.push(c);
132+
if c == '%' {
133+
if let Some(&n) = chars.peek() {
134+
if n == '%' {
135+
chars.next();
136+
res.push('%');
137+
} else {
138+
res.push(flag);
139+
}
140+
}
141+
}
142+
}
143+
res
144+
}
145+
146+
/// Retrieves the date/time format string from the system locale (D_T_FMT, D_FMT, T_FMT)
147+
pub fn get_langinfo(item: libc::nl_item) -> Option<String> {
58148
unsafe {
59149
// Set locale from environment variables
60150
libc::setlocale(libc::LC_TIME, c"".as_ptr());
61151

62152
// Get the date/time format string
63-
let d_t_fmt_ptr = libc::nl_langinfo(libc::D_T_FMT);
64-
if d_t_fmt_ptr.is_null() {
153+
let fmt_ptr = libc::nl_langinfo(item);
154+
if fmt_ptr.is_null() {
65155
return None;
66156
}
67157

68-
let format = CStr::from_ptr(d_t_fmt_ptr).to_str().ok()?;
158+
let format = CStr::from_ptr(fmt_ptr).to_str().ok()?;
69159
if format.is_empty() {
70160
return None;
71161
}
@@ -74,6 +164,11 @@ cfg_langinfo! {
74164
}
75165
}
76166

167+
/// Retrieves the date/time format string from the system locale
168+
fn get_locale_format_string() -> Option<String> {
169+
get_langinfo(libc::D_T_FMT)
170+
}
171+
77172
/// Ensures the format string includes timezone (%Z)
78173
fn ensure_timezone_in_format(format: &str) -> String {
79174
if format.contains("%Z") {
@@ -107,6 +202,18 @@ pub fn get_locale_default_format() -> &'static str {
107202
"%a %b %e %X %Z %Y"
108203
}
109204

205+
#[cfg(not(any(
206+
target_os = "linux",
207+
target_vendor = "apple",
208+
target_os = "freebsd",
209+
target_os = "netbsd",
210+
target_os = "openbsd",
211+
target_os = "dragonfly"
212+
)))]
213+
pub fn expand_locale_format(format: &str) -> std::borrow::Cow<str> {
214+
std::borrow::Cow::Borrowed(format)
215+
}
216+
110217
#[cfg(test)]
111218
mod tests {
112219
cfg_langinfo! {

tests/by-util/test_date.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,3 +1404,19 @@ fn test_date_locale_fr_french() {
14041404
"Output should include timezone information, got: {stdout}"
14051405
);
14061406
}
1407+
1408+
#[test]
1409+
fn test_format_upper_c_locale_expansion() {
1410+
new_ucmd!()
1411+
.env("LC_ALL", "C")
1412+
.env("TZ", "UTC")
1413+
.arg("-d")
1414+
.arg("2024-01-01")
1415+
.arg("+%^c")
1416+
.succeeds()
1417+
.stdout_str_check(|out| {
1418+
// In C locale, %c expands to "%a %b %e %H:%M:%S %Y" (e.g., "Mon Jan 1 ...")
1419+
// With the ^ flag, we expect "MON JAN 1 ..."
1420+
out.contains("MON") && out.contains("JAN")
1421+
});
1422+
}

0 commit comments

Comments
 (0)