Skip to content

Commit 893c2ae

Browse files
committed
date: don't pad locale month/weekday names with trailing spaces
The ls-specific column alignment padding (from abmon-align) was leaking into date output. Add a `pad` parameter to `localize_format_string` so ls gets padded names and date gets raw names.
1 parent e4c0c6f commit 893c2ae

File tree

4 files changed

+133
-22
lines changed

4 files changed

+133
-22
lines changed

src/uu/date/src/date.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use uucore::display::Quotable;
2222
use uucore::error::FromIo;
2323
use uucore::error::{UResult, USimpleError};
2424
#[cfg(feature = "i18n-datetime")]
25-
use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale};
25+
use uucore::i18n::datetime::{NamePadding, localize_format_string, should_use_icu_locale};
2626
use uucore::translate;
2727
use uucore::{format_usage, show};
2828
#[cfg(windows)]
@@ -715,7 +715,7 @@ fn format_date_with_locale_aware_months(
715715
// rest of the function without a dangling reference.
716716
#[cfg(feature = "i18n-datetime")]
717717
let localized: Option<String> = (!skip_localization && should_use_icu_locale())
718-
.then(|| localize_format_string(format_string, date.date()));
718+
.then(|| localize_format_string(format_string, date.date(), NamePadding::Raw));
719719
#[cfg(feature = "i18n-datetime")]
720720
let fmt: &str = localized.as_deref().unwrap_or(format_string);
721721
#[cfg(not(feature = "i18n-datetime"))]

src/uucore/src/lib/features/i18n/datetime.rs

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ use jiff_icu::ConvertFrom;
1717
use std::sync::OnceLock;
1818

1919
use crate::i18n::get_locale_from_env;
20+
/// Controls whether locale name lookups return raw or padded names.
21+
#[derive(Clone, Copy)]
22+
pub enum NamePadding {
23+
/// Raw names with no trailing padding — for `date` and similar utilities.
24+
Raw,
25+
/// Names padded to uniform display width — for columnar output like `ls`.
26+
Padded,
27+
}
2028

2129
/// Get the locale for time/date formatting from LC_TIME environment variable
2230
pub fn get_time_locale() -> &'static (Locale, super::UEncoding) {
@@ -104,15 +112,26 @@ fn pad_names<const N: usize>(names: [String; N]) -> [String; N] {
104112
/// Cached locale name arrays, computed once per process. Each variant is
105113
/// `None` when the ICU formatter for that field width cannot be created
106114
/// (should only happen for truly broken locale data).
115+
///
116+
/// Both raw and padded variants are stored: `date` needs raw names (no
117+
/// trailing spaces) while `ls` needs padded names for column alignment.
107118
struct CachedLocaleNames {
108-
/// `%B` — full month names, padded to uniform display width
119+
/// `%B` — full month names, raw
109120
month_long: Option<[String; 12]>,
110-
/// `%b` / `%h` — abbreviated month names (trailing dots stripped), padded
121+
/// `%B` — full month names, padded to uniform display width
122+
month_long_padded: Option<[String; 12]>,
123+
/// `%b` / `%h` — abbreviated month names (trailing dots stripped), raw
111124
month_abbrev: Option<[String; 12]>,
112-
/// `%A` — full weekday names, padded
125+
/// `%b` / `%h` — abbreviated month names, padded
126+
month_abbrev_padded: Option<[String; 12]>,
127+
/// `%A` — full weekday names, raw
113128
weekday_long: Option<[String; 7]>,
114-
/// `%a` — abbreviated weekday names, padded
129+
/// `%A` — full weekday names, padded
130+
weekday_long_padded: Option<[String; 7]>,
131+
/// `%a` — abbreviated weekday names, raw
115132
weekday_short: Option<[String; 7]>,
133+
/// `%a` — abbreviated weekday names, padded
134+
weekday_short_padded: Option<[String; 7]>,
116135
}
117136

118137
/// Return the cached, pre-padded locale names (computed once per process).
@@ -141,40 +160,50 @@ fn get_cached_locale_names() -> &'static CachedLocaleNames {
141160

142161
let month_long = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::long())
143162
.ok()
144-
.map(|f| pad_names(month_dates.each_ref().map(|d| f.format(d).to_string())));
163+
.map(|f| month_dates.each_ref().map(|d| f.format(d).to_string()));
164+
let month_long_padded = month_long.clone().map(pad_names);
145165

146166
// ICU's medium format may include trailing periods (e.g., "febr."
147167
// for Hungarian). The standard C/POSIX locale via nl_langinfo
148168
// returns abbreviations WITHOUT trailing periods, so we strip them.
149169
let month_abbrev = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::medium())
150170
.ok()
151171
.map(|f| {
152-
pad_names(
153-
month_dates
154-
.each_ref()
155-
.map(|d| f.format(d).to_string().trim_end_matches('.').to_string()),
156-
)
172+
month_dates
173+
.each_ref()
174+
.map(|d| f.format(d).to_string().trim_end_matches('.').to_string())
157175
});
176+
let month_abbrev_padded = month_abbrev.clone().map(pad_names);
158177

159178
let weekday_long = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::long())
160179
.ok()
161-
.map(|f| pad_names(weekday_dates.each_ref().map(|d| f.format(d).to_string())));
180+
.map(|f| weekday_dates.each_ref().map(|d| f.format(d).to_string()));
181+
let weekday_long_padded = weekday_long.clone().map(pad_names);
162182

163183
let weekday_short = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::short())
164184
.ok()
165-
.map(|f| pad_names(weekday_dates.each_ref().map(|d| f.format(d).to_string())));
185+
.map(|f| weekday_dates.each_ref().map(|d| f.format(d).to_string()));
186+
let weekday_short_padded = weekday_short.clone().map(pad_names);
166187

167188
CachedLocaleNames {
168189
month_long,
190+
month_long_padded,
169191
month_abbrev,
192+
month_abbrev_padded,
170193
weekday_long,
194+
weekday_long_padded,
171195
weekday_short,
196+
weekday_short_padded,
172197
}
173198
})
174199
}
175200

176-
/// Transform a strftime format string to use locale-specific calendar values
177-
pub fn localize_format_string(format: &str, date: JiffDate) -> String {
201+
/// Transform a strftime format string to use locale-specific calendar values.
202+
///
203+
/// When `padding` is [`NamePadding::Padded`], month and weekday names are
204+
/// padded to uniform display width (for columnar output like `ls`). When
205+
/// [`NamePadding::Raw`], raw names are used (for `date` and similar utilities).
206+
pub fn localize_format_string(format: &str, date: JiffDate, padding: NamePadding) -> String {
178207
const PERCENT_PLACEHOLDER: &str = "\x00\x00";
179208

180209
let (locale, _) = get_time_locale();
@@ -219,30 +248,51 @@ pub fn localize_format_string(format: &str, date: JiffDate) -> String {
219248
.replace("%e", &format!("{cal_day:2}"));
220249
}
221250

222-
// Look up the pre-padded locale name from the once-per-process cache.
251+
// Look up locale names from the once-per-process cache.
252+
let pad = matches!(padding, NamePadding::Padded);
223253
let cached = get_cached_locale_names();
224254
let month_idx = date.month() as usize - 1;
225255
let weekday_idx = date.weekday().to_monday_zero_offset() as usize;
226256

227257
if fmt.contains("%B") {
228-
if let Some(names) = &cached.month_long {
258+
let src = if pad {
259+
&cached.month_long_padded
260+
} else {
261+
&cached.month_long
262+
};
263+
if let Some(names) = src {
229264
fmt = fmt.replace("%B", &names[month_idx]);
230265
}
231266
}
232267
if fmt.contains("%b") || fmt.contains("%h") {
233-
if let Some(names) = &cached.month_abbrev {
268+
let src = if pad {
269+
&cached.month_abbrev_padded
270+
} else {
271+
&cached.month_abbrev
272+
};
273+
if let Some(names) = src {
234274
fmt = fmt
235275
.replace("%b", &names[month_idx])
236276
.replace("%h", &names[month_idx]);
237277
}
238278
}
239279
if fmt.contains("%A") {
240-
if let Some(names) = &cached.weekday_long {
280+
let src = if pad {
281+
&cached.weekday_long_padded
282+
} else {
283+
&cached.weekday_long
284+
};
285+
if let Some(names) = src {
241286
fmt = fmt.replace("%A", &names[weekday_idx]);
242287
}
243288
}
244289
if fmt.contains("%a") {
245-
if let Some(names) = &cached.weekday_short {
290+
let src = if pad {
291+
&cached.weekday_short_padded
292+
} else {
293+
&cached.weekday_short
294+
};
295+
if let Some(names) = src {
246296
fmt = fmt.replace("%a", &names[weekday_idx]);
247297
}
248298
}

src/uucore/src/lib/features/time.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ pub fn format_system_time_locale_aware<W: Write>(
130130
use crate::i18n::datetime::{localize_format_string, should_use_icu_locale};
131131
if should_use_icu_locale() {
132132
if let Ok(zoned) = <SystemTime as TryInto<Zoned>>::try_into(time) {
133-
let localized = localize_format_string(fmt, zoned.date());
133+
let localized = localize_format_string(fmt, zoned.date(), padding);
134134
return format_zoned(out, zoned, &localized);
135135
}
136136
// Out-of-range: fall through to the plain fallback below.

tests/by-util/test_date.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2224,6 +2224,67 @@ fn test_date_thai_locale_solar_calendar() {
22242224
assert!(rfc_output.starts_with(&current_year.to_string()));
22252225
}
22262226

2227+
/// Regression test: `date +%B` / `+%b` / `+%A` / `+%a` must not have trailing
2228+
/// padding spaces. The ls-specific column alignment padding must not leak into
2229+
/// date output.
2230+
#[cfg(unix)]
2231+
#[test]
2232+
fn test_date_month_weekday_names_no_trailing_spaces() {
2233+
let current_year: i32 = new_ucmd!()
2234+
.env("LC_ALL", "C")
2235+
.arg("+%Y")
2236+
.succeeds()
2237+
.stdout_str()
2238+
.trim()
2239+
.parse()
2240+
.unwrap();
2241+
2242+
for locale in ["fr_FR.UTF-8", "th_TH.UTF-8", "fi_FI.UTF-8"] {
2243+
if !is_locale_available(locale) {
2244+
continue;
2245+
}
2246+
// Check month names (%B, %b) for all 12 months
2247+
for month in 1..=12 {
2248+
for fmt in ["+%B", "+%b"] {
2249+
let output = new_ucmd!()
2250+
.env("LC_ALL", locale)
2251+
.arg("--date")
2252+
.arg(format!("{current_year}-{month:02}-01"))
2253+
.arg(fmt)
2254+
.succeeds()
2255+
.stdout_str()
2256+
.to_string();
2257+
let name = output.trim_end_matches('\n');
2258+
assert_eq!(
2259+
name,
2260+
name.trim_end(),
2261+
"[{locale}] {fmt} month {month:02} has trailing spaces: {name:?}"
2262+
);
2263+
}
2264+
}
2265+
// Check weekday names (%A, %a) for 7 consecutive days (Apr 6–12)
2266+
for day_offset in 0..7 {
2267+
let day = 6 + day_offset;
2268+
for fmt in ["+%A", "+%a"] {
2269+
let output = new_ucmd!()
2270+
.env("LC_ALL", locale)
2271+
.arg("--date")
2272+
.arg(format!("{current_year}-04-{day:02}"))
2273+
.arg(fmt)
2274+
.succeeds()
2275+
.stdout_str()
2276+
.to_string();
2277+
let name = output.trim_end_matches('\n');
2278+
assert_eq!(
2279+
name,
2280+
name.trim_end(),
2281+
"[{locale}] {fmt} day offset {day_offset} has trailing spaces: {name:?}"
2282+
);
2283+
}
2284+
}
2285+
}
2286+
}
2287+
22272288
#[cfg(unix)]
22282289
fn check_date(locale: &str, date: &str, fmt: &str, expected: &str) {
22292290
let actual = new_ucmd!()

0 commit comments

Comments
 (0)