Skip to content

Commit f494fa5

Browse files
committed
date: fix handling of case-change flags in locale format specifiers
1 parent bea05bb commit f494fa5

File tree

3 files changed

+138
-6
lines changed

3 files changed

+138
-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
@@ -64,8 +64,98 @@ cfg_langinfo! {
6464
})
6565
}
6666

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

78168
// Get the date/time format string
79-
let d_t_fmt_ptr = libc::nl_langinfo(libc::D_T_FMT);
80-
if d_t_fmt_ptr.is_null() {
169+
let fmt_ptr = libc::nl_langinfo(item);
170+
if fmt_ptr.is_null() {
81171
return None;
82172
}
83173

84-
let format = CStr::from_ptr(d_t_fmt_ptr).to_str().ok()?;
174+
let format = CStr::from_ptr(fmt_ptr).to_str().ok()?;
85175
if format.is_empty() {
86176
return None;
87177
}
@@ -90,6 +180,11 @@ cfg_langinfo! {
90180
}
91181
}
92182

183+
/// Retrieves the date/time format string from the system locale
184+
fn get_locale_format_string() -> Option<String> {
185+
get_langinfo(libc::D_T_FMT)
186+
}
187+
93188
/// Ensures the format string includes timezone (%Z)
94189
fn ensure_timezone_in_format(format: &str) -> String {
95190
if format.contains("%Z") {
@@ -123,6 +218,18 @@ pub fn get_locale_default_format() -> &'static str {
123218
"%a %b %e %X %Z %Y"
124219
}
125220

221+
#[cfg(not(any(
222+
target_os = "linux",
223+
target_vendor = "apple",
224+
target_os = "freebsd",
225+
target_os = "netbsd",
226+
target_os = "openbsd",
227+
target_os = "dragonfly"
228+
)))]
229+
pub fn expand_locale_format(format: &str) -> std::borrow::Cow<'_, str> {
230+
std::borrow::Cow::Borrowed(format)
231+
}
232+
126233
#[cfg(test)]
127234
mod tests {
128235
cfg_langinfo! {

tests/by-util/test_date.rs

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

0 commit comments

Comments
 (0)