Skip to content

Commit 7856aaf

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 ac09cbd commit 7856aaf

File tree

4 files changed

+126
-22
lines changed

4 files changed

+126
-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)]
@@ -727,7 +727,7 @@ fn format_date_with_locale_aware_months(
727727
let result = if !should_use_icu_locale() || skip_localization {
728728
broken_down.to_string_with_config(config, format_string)
729729
} else {
730-
let fmt = localize_format_string(format_string, date.date());
730+
let fmt = localize_format_string(format_string, date.date(), NamePadding::Raw);
731731
broken_down.to_string_with_config(config, &fmt)
732732
};
733733
#[cfg(not(feature = "i18n-datetime"))]

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

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

1919
use crate::i18n::get_locale_from_env;
20+
pub use crate::time::NamePadding;
2021

2122
/// Get the locale for time/date formatting from LC_TIME environment variable
2223
pub fn get_time_locale() -> &'static (Locale, super::UEncoding) {
@@ -104,15 +105,26 @@ fn pad_names<const N: usize>(names: [String; N]) -> [String; N] {
104105
/// Cached locale name arrays, computed once per process. Each variant is
105106
/// `None` when the ICU formatter for that field width cannot be created
106107
/// (should only happen for truly broken locale data).
108+
///
109+
/// Both raw and padded variants are stored: `date` needs raw names (no
110+
/// trailing spaces) while `ls` needs padded names for column alignment.
107111
struct CachedLocaleNames {
108-
/// `%B` — full month names, padded to uniform display width
112+
/// `%B` — full month names, raw
109113
month_long: Option<[String; 12]>,
110-
/// `%b` / `%h` — abbreviated month names (trailing dots stripped), padded
114+
/// `%B` — full month names, padded to uniform display width
115+
month_long_padded: Option<[String; 12]>,
116+
/// `%b` / `%h` — abbreviated month names (trailing dots stripped), raw
111117
month_abbrev: Option<[String; 12]>,
112-
/// `%A` — full weekday names, padded
118+
/// `%b` / `%h` — abbreviated month names, padded
119+
month_abbrev_padded: Option<[String; 12]>,
120+
/// `%A` — full weekday names, raw
113121
weekday_long: Option<[String; 7]>,
114-
/// `%a` — abbreviated weekday names, padded
122+
/// `%A` — full weekday names, padded
123+
weekday_long_padded: Option<[String; 7]>,
124+
/// `%a` — abbreviated weekday names, raw
115125
weekday_short: Option<[String; 7]>,
126+
/// `%a` — abbreviated weekday names, padded
127+
weekday_short_padded: Option<[String; 7]>,
116128
}
117129

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

142154
let month_long = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::long())
143155
.ok()
144-
.map(|f| pad_names(month_dates.each_ref().map(|d| f.format(d).to_string())));
156+
.map(|f| month_dates.each_ref().map(|d| f.format(d).to_string()));
157+
let month_long_padded = month_long.clone().map(pad_names);
145158

146159
// ICU's medium format may include trailing periods (e.g., "febr."
147160
// for Hungarian). The standard C/POSIX locale via nl_langinfo
148161
// returns abbreviations WITHOUT trailing periods, so we strip them.
149162
let month_abbrev = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::medium())
150163
.ok()
151164
.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-
)
165+
month_dates
166+
.each_ref()
167+
.map(|d| f.format(d).to_string().trim_end_matches('.').to_string())
157168
});
169+
let month_abbrev_padded = month_abbrev.clone().map(pad_names);
158170

159171
let weekday_long = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::long())
160172
.ok()
161-
.map(|f| pad_names(weekday_dates.each_ref().map(|d| f.format(d).to_string())));
173+
.map(|f| weekday_dates.each_ref().map(|d| f.format(d).to_string()));
174+
let weekday_long_padded = weekday_long.clone().map(pad_names);
162175

163176
let weekday_short = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::short())
164177
.ok()
165-
.map(|f| pad_names(weekday_dates.each_ref().map(|d| f.format(d).to_string())));
178+
.map(|f| weekday_dates.each_ref().map(|d| f.format(d).to_string()));
179+
let weekday_short_padded = weekday_short.clone().map(pad_names);
166180

167181
CachedLocaleNames {
168182
month_long,
183+
month_long_padded,
169184
month_abbrev,
185+
month_abbrev_padded,
170186
weekday_long,
187+
weekday_long_padded,
171188
weekday_short,
189+
weekday_short_padded,
172190
}
173191
})
174192
}
175193

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

180202
let (locale, _) = get_time_locale();
@@ -219,30 +241,51 @@ pub fn localize_format_string(format: &str, date: JiffDate) -> String {
219241
.replace("%e", &format!("{cal_day:2}"));
220242
}
221243

222-
// Look up the pre-padded locale name from the once-per-process cache.
244+
// Look up locale names from the once-per-process cache.
245+
let pad = matches!(padding, NamePadding::Padded);
223246
let cached = get_cached_locale_names();
224247
let month_idx = date.month() as usize - 1;
225248
let weekday_idx = date.weekday().to_monday_zero_offset() as usize;
226249

227250
if fmt.contains("%B") {
228-
if let Some(names) = &cached.month_long {
251+
let src = if pad {
252+
&cached.month_long_padded
253+
} else {
254+
&cached.month_long
255+
};
256+
if let Some(names) = src {
229257
fmt = fmt.replace("%B", &names[month_idx]);
230258
}
231259
}
232260
if fmt.contains("%b") || fmt.contains("%h") {
233-
if let Some(names) = &cached.month_abbrev {
261+
let src = if pad {
262+
&cached.month_abbrev_padded
263+
} else {
264+
&cached.month_abbrev
265+
};
266+
if let Some(names) = src {
234267
fmt = fmt
235268
.replace("%b", &names[month_idx])
236269
.replace("%h", &names[month_idx]);
237270
}
238271
}
239272
if fmt.contains("%A") {
240-
if let Some(names) = &cached.weekday_long {
273+
let src = if pad {
274+
&cached.weekday_long_padded
275+
} else {
276+
&cached.weekday_long
277+
};
278+
if let Some(names) = src {
241279
fmt = fmt.replace("%A", &names[weekday_idx]);
242280
}
243281
}
244282
if fmt.contains("%a") {
245-
if let Some(names) = &cached.weekday_short {
283+
let src = if pad {
284+
&cached.weekday_short_padded
285+
} else {
286+
&cached.weekday_short
287+
};
288+
if let Some(names) = src {
246289
fmt = fmt.replace("%a", &names[weekday_idx]);
247290
}
248291
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ pub fn format_system_time_locale_aware<W: Write>(
128128
use crate::i18n::datetime::{localize_format_string, should_use_icu_locale};
129129
if should_use_icu_locale() {
130130
if let Ok(zoned) = <SystemTime as TryInto<Zoned>>::try_into(time) {
131-
let localized = localize_format_string(fmt, zoned.date());
131+
let localized = localize_format_string(fmt, zoned.date(), padding);
132132
return format_zoned(out, zoned, &localized);
133133
}
134134
// 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
@@ -2198,6 +2198,67 @@ fn test_date_thai_locale_solar_calendar() {
21982198
assert!(rfc_output.starts_with(&current_year.to_string()));
21992199
}
22002200

2201+
/// Regression test: `date +%B` / `+%b` / `+%A` / `+%a` must not have trailing
2202+
/// padding spaces. The ls-specific column alignment padding must not leak into
2203+
/// date output.
2204+
#[cfg(unix)]
2205+
#[test]
2206+
fn test_date_month_weekday_names_no_trailing_spaces() {
2207+
let current_year: i32 = new_ucmd!()
2208+
.env("LC_ALL", "C")
2209+
.arg("+%Y")
2210+
.succeeds()
2211+
.stdout_str()
2212+
.trim()
2213+
.parse()
2214+
.unwrap();
2215+
2216+
for locale in ["fr_FR.UTF-8", "th_TH.UTF-8", "fi_FI.UTF-8"] {
2217+
if !is_locale_available(locale) {
2218+
continue;
2219+
}
2220+
// Check month names (%B, %b) for all 12 months
2221+
for month in 1..=12 {
2222+
for fmt in ["+%B", "+%b"] {
2223+
let output = new_ucmd!()
2224+
.env("LC_ALL", locale)
2225+
.arg("--date")
2226+
.arg(format!("{current_year}-{month:02}-01"))
2227+
.arg(fmt)
2228+
.succeeds()
2229+
.stdout_str()
2230+
.to_string();
2231+
let name = output.trim_end_matches('\n');
2232+
assert_eq!(
2233+
name,
2234+
name.trim_end(),
2235+
"[{locale}] {fmt} month {month:02} has trailing spaces: {name:?}"
2236+
);
2237+
}
2238+
}
2239+
// Check weekday names (%A, %a) for 7 consecutive days (Apr 6–12)
2240+
for day_offset in 0..7 {
2241+
let day = 6 + day_offset;
2242+
for fmt in ["+%A", "+%a"] {
2243+
let output = new_ucmd!()
2244+
.env("LC_ALL", locale)
2245+
.arg("--date")
2246+
.arg(format!("{current_year}-04-{day:02}"))
2247+
.arg(fmt)
2248+
.succeeds()
2249+
.stdout_str()
2250+
.to_string();
2251+
let name = output.trim_end_matches('\n');
2252+
assert_eq!(
2253+
name,
2254+
name.trim_end(),
2255+
"[{locale}] {fmt} day offset {day_offset} has trailing spaces: {name:?}"
2256+
);
2257+
}
2258+
}
2259+
}
2260+
}
2261+
22012262
#[cfg(unix)]
22022263
fn check_date(locale: &str, date: &str, fmt: &str, expected: &str) {
22032264
let actual = new_ucmd!()

0 commit comments

Comments
 (0)