date: use jiff-icu for locale calendar conversions

This commit is contained in:
Christopher Dryden
2026-01-26 20:27:46 +00:00
parent aee49f1e10
commit 76897d7cac
7 changed files with 203 additions and 295 deletions
Generated
+24 -10
View File
@@ -337,18 +337,18 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.55"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.55"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstream",
"anstyle",
@@ -1008,7 +1008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1355,6 +1355,7 @@ dependencies = [
"icu_locale_core",
"icu_provider",
"ixdtf",
"serde",
"tinystr",
"zerovec",
]
@@ -1720,7 +1721,18 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "jiff-icu"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e67c2beaae8b10a82d849b9aabb698a43a682f32b17bcdc035d5ecadb44d646"
dependencies = [
"icu_calendar",
"icu_time",
"jiff",
]
[[package]]
@@ -2003,7 +2015,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2593,7 +2605,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2903,7 +2915,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3378,6 +3390,7 @@ dependencies = [
"icu_calendar",
"icu_locale",
"jiff",
"jiff-icu",
"nix",
"parse_datetime",
"tempfile",
@@ -4456,6 +4469,7 @@ dependencies = [
"icu_provider",
"itertools 0.14.0",
"jiff",
"jiff-icu",
"libc",
"md-5",
"memchr",
@@ -4649,7 +4663,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -346,6 +346,7 @@ indicatif = "0.18.0"
itertools = "0.14.0"
itoa = "1.0.15"
jiff = "0.2.18"
jiff-icu = "0.2.2"
libc = "0.2.172"
lscolors = { version = "0.21.0", default-features = false, features = [
"gnu_legacy",
+2 -1
View File
@@ -20,13 +20,14 @@ path = "src/date.rs"
[features]
default = ["i18n-datetime"]
i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale"]
i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale", "dep:jiff-icu"]
[dependencies]
clap = { workspace = true }
fluent = { workspace = true }
icu_calendar = { workspace = true, optional = true }
icu_locale = { workspace = true, optional = true }
jiff-icu = { workspace = true, optional = true }
jiff = { workspace = true, features = [
"tzdb-bundle-platform",
"tzdb-zoneinfo",
+6 -107
View File
@@ -21,10 +21,7 @@ use uucore::display::Quotable;
use uucore::error::FromIo;
use uucore::error::{UResult, USimpleError};
#[cfg(feature = "i18n-datetime")]
use uucore::i18n::datetime::{
get_era_year, get_localized_day_name, get_localized_month_name, get_time_locale,
should_use_icu_locale,
};
use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale};
use uucore::translate;
use uucore::{format_usage, show};
#[cfg(windows)]
@@ -620,112 +617,14 @@ fn format_date_with_locale_aware_months(
format_string: &str,
config: &Config<PosixCustom>,
) -> Result<String, jiff::Error> {
// Only use ICU for non-English locales and when format string contains month, day, or era year specifiers
if (format_string.contains("%B")
|| format_string.contains("%b")
|| format_string.contains("%A")
|| format_string.contains("%a")
|| format_string.contains("%Y")
|| format_string.contains("%Ey"))
&& should_use_icu_locale()
{
let broken_down = BrokenDownTime::from(date);
// Get localized month names if needed
let (full_month, abbrev_month) =
if format_string.contains("%B") || format_string.contains("%b") {
if let Some(month_val) = broken_down.month() {
let month_u8 = if (1..=12).contains(&month_val) {
month_val as u8
} else {
1 // fallback to January for invalid values
};
(
get_localized_month_name(month_u8, true),
get_localized_month_name(month_u8, false),
)
} else {
(String::new(), String::new())
}
} else {
(String::new(), String::new())
};
let broken_down = BrokenDownTime::from(date);
// Get localized day names if needed
let (full_day, abbrev_day) = if format_string.contains("%A") || format_string.contains("%a")
{
if let (Some(year), Some(month), Some(day)) =
(broken_down.year(), broken_down.month(), broken_down.day())
{
(
get_localized_day_name(year.into(), month as u8, day as u8, true),
get_localized_day_name(year.into(), month as u8, day as u8, false),
)
} else {
(String::new(), String::new())
}
} else {
(String::new(), String::new())
};
// Get era year if needed
let era_year = if format_string.contains("%Y") || format_string.contains("%Ey") {
if let (Some(year), Some(month), Some(day)) =
(broken_down.year(), broken_down.month(), broken_down.day())
{
let (locale, _encoding) = get_time_locale();
get_era_year(year.into(), month as u8, day as u8, locale)
} else {
None
}
} else {
None
};
// Replace format specifiers with NULL-byte placeholders for successful ICU translations only
// Use NULL bytes to avoid collision with user format strings
let mut temp_format = format_string.to_string();
if !full_month.is_empty() {
temp_format = temp_format.replace("%B", "\0FULL_MONTH\0");
}
if !abbrev_month.is_empty() {
temp_format = temp_format.replace("%b", "\0ABBREV_MONTH\0");
}
if !full_day.is_empty() {
temp_format = temp_format.replace("%A", "\0FULL_DAY\0");
}
if !abbrev_day.is_empty() {
temp_format = temp_format.replace("%a", "\0ABBREV_DAY\0");
}
if era_year.is_some() {
temp_format = temp_format.replace("%Y", "\0ERA_YEAR\0");
}
// Format with the temporary string
let temp_result = broken_down.to_string_with_config(config, &temp_format)?;
// Replace NULL-byte placeholders with localized names
let mut final_result = temp_result;
if !full_month.is_empty() {
final_result = final_result.replace("\0FULL_MONTH\0", &full_month);
}
if !abbrev_month.is_empty() {
final_result = final_result.replace("\0ABBREV_MONTH\0", &abbrev_month);
}
if !full_day.is_empty() {
final_result = final_result.replace("\0FULL_DAY\0", &full_day);
}
if !abbrev_day.is_empty() {
final_result = final_result.replace("\0ABBREV_DAY\0", &abbrev_day);
}
if let Some(era_year_val) = era_year {
final_result = final_result.replace("\0ERA_YEAR\0", &era_year_val.to_string());
}
return Ok(final_result);
if !should_use_icu_locale() {
return broken_down.to_string_with_config(config, format_string);
}
// Fallback to regular formatting
BrokenDownTime::from(date).to_string_with_config(config, format_string)
let fmt = localize_format_string(format_string, &date.date());
broken_down.to_string_with_config(config, &fmt)
}
/// Return the appropriate format string for the given settings.
+2 -1
View File
@@ -82,6 +82,7 @@ icu_decimal = { workspace = true, optional = true, features = [
] }
icu_locale = { workspace = true, optional = true, features = ["compiled_data"] }
icu_provider = { workspace = true, optional = true }
jiff-icu = { workspace = true, optional = true }
# Fluent dependencies (always available for localization)
fluent = { workspace = true }
@@ -153,7 +154,7 @@ i18n-all = ["i18n-collator", "i18n-decimal", "i18n-datetime"]
i18n-common = ["icu_locale"]
i18n-collator = ["i18n-common", "icu_collator"]
i18n-decimal = ["i18n-common", "icu_decimal", "icu_provider"]
i18n-datetime = ["i18n-common", "icu_calendar", "icu_datetime"]
i18n-datetime = ["i18n-common", "icu_calendar", "icu_datetime", "jiff-icu", "jiff"]
mode = ["libc"]
perms = ["entries", "libc", "walkdir"]
buf-copy = []
+67 -175
View File
@@ -3,13 +3,17 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Locale-aware datetime formatting utilities using ICU
// spell-checker:ignore fieldsets janvier
// spell-checker:ignore fieldsets prefs
//! Locale-aware datetime formatting utilities using ICU and jiff-icu
use icu_calendar::Date;
use icu_calendar::cal::{Buddhist, Ethiopian, Iso, Persian};
use icu_datetime::DateTimeFormatter;
use icu_datetime::fieldsets;
use icu_locale::Locale;
use jiff::civil::Date as JiffDate;
use jiff_icu::ConvertFrom;
use std::sync::OnceLock;
use crate::i18n::get_locale_from_env;
@@ -34,88 +38,6 @@ pub fn should_use_icu_locale() -> bool {
*locale != locale!("und")
}
/// Get a localized month name for the given month number (1-12)
///
/// # Arguments
/// * `month` - Month number (1 = January, 2 = February, etc.)
/// * `full` - If true, return full month name (e.g., "January"), otherwise abbreviated (e.g., "Jan")
///
/// # Returns
/// Localized month name, or falls back to English if locale is not supported
pub fn get_localized_month_name(month: u8, full: bool) -> String {
// Get locale from environment
let (locale, _encoding) = get_time_locale();
// Create a date with the specified month (use year 2000, day 1 as arbitrary values)
let Ok(date) = Date::try_new_gregorian(2000, month, 1) else {
// Invalid month, return empty string to signal failure
return String::new();
};
// Configure field set for month formatting
// Use Year-Month-Day format to ensure we get textual month names
let field_set = if full {
fieldsets::YMD::long()
} else {
fieldsets::YMD::medium()
};
// Create formatter with locale
let Ok(formatter) = DateTimeFormatter::try_new(locale.clone().into(), field_set) else {
// Failed to create formatter, return empty string to signal failure
return String::new();
};
// Format the date to get full date, then extract month
let formatted = formatter.format(&date).to_string();
// Extract month name from formatted date like "15 janvier 2000" or "2000-01-15"
// Look for a word that contains letters (the month name)
let words: Vec<&str> = formatted.split_whitespace().collect();
// Return the month name as extracted from ICU (no further processing needed)
// ICU already handles the full vs abbreviated formatting correctly
words
.iter()
.find(|word| word.chars().any(|c| c.is_alphabetic()))
.map_or_else(String::new, |s| (*s).to_string())
}
/// Get a localized day name for the given date components
///
/// # Arguments
/// * `year` - The year
/// * `month` - The month (1-12)
/// * `day` - The day of the month
/// * `full` - If true, return full day name (e.g., "Monday"), otherwise abbreviated (e.g., "Mon")
///
/// # Returns
/// Localized day name, or falls back to empty string if locale is not supported
pub fn get_localized_day_name(year: i32, month: u8, day: u8, full: bool) -> String {
// Create ICU Date from components
let Ok(date) = Date::try_new_gregorian(year, month, day) else {
return String::new();
};
// Get locale from environment
let (locale, _encoding) = get_time_locale();
// Configure field set for day formatting
let field_set = if full {
fieldsets::E::long() // Full day name
} else {
fieldsets::E::short() // Abbreviated day name
};
// Create formatter with locale
let Ok(formatter) = DateTimeFormatter::try_new(locale.clone().into(), field_set) else {
return String::new();
};
// Format the date to get day name
let formatted = formatter.format(&date).to_string();
formatted.trim().to_string()
}
/// Determine the appropriate calendar system for a given locale
pub fn get_locale_calendar_type(locale: &Locale) -> CalendarType {
let locale_str = locale.to_string();
@@ -145,120 +67,90 @@ pub enum CalendarType {
Ethiopian,
}
/// Convert a Gregorian date to the appropriate calendar system for a locale
///
/// # Arguments
/// * `year` - Gregorian year
/// * `month` - Month (1-12)
/// * `day` - Day (1-31)
/// * `calendar_type` - Target calendar system
///
/// # Returns
/// * `Some((era_year, month, day))` - Date in target calendar system
/// * `None` - If conversion fails
pub fn convert_date_to_locale_calendar(
year: i32,
month: u8,
day: u8,
calendar_type: &CalendarType,
) -> Option<(i32, u8, u8)> {
match calendar_type {
CalendarType::Gregorian => Some((year, month, day)),
CalendarType::Buddhist => {
// Buddhist calendar: Gregorian year + 543
Some((year + 543, month, day))
}
CalendarType::Persian => {
// Persian calendar conversion (Solar Hijri)
// March 21 (Nowruz) is roughly the start of the Persian year
let persian_year = if month > 3 || (month == 3 && day >= 21) {
year - 621 // After March 21
} else {
year - 622 // Before March 21
};
Some((persian_year, month, day))
}
CalendarType::Ethiopian => {
// Ethiopian calendar conversion
// September 11/12 is roughly the start of the Ethiopian year
let ethiopian_year = if month > 9 || (month == 9 && day >= 11) {
year - 7 // After September 11
} else {
year - 8 // Before September 11
};
Some((ethiopian_year, month, day))
}
}
}
/// Transform a strftime format string to use locale-specific calendar values
pub fn localize_format_string(format: &str, date: &JiffDate) -> String {
let (locale, _) = get_time_locale();
let iso_date = Date::<Iso>::convert_from(*date);
/// Get the era year for a given date and locale
pub fn get_era_year(year: i32, month: u8, day: u8, locale: &Locale) -> Option<i32> {
// Validate input date
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let mut fmt = format.to_string();
// For non-Gregorian calendars, replace date components with converted values
let calendar_type = get_locale_calendar_type(locale);
match calendar_type {
CalendarType::Gregorian => None,
_ => convert_date_to_locale_calendar(year, month, day, &calendar_type)
.map(|(era_year, _, _)| era_year),
if calendar_type != CalendarType::Gregorian {
let (cal_year, cal_month, cal_day) = match calendar_type {
CalendarType::Buddhist => {
let d = iso_date.to_calendar(Buddhist);
(d.extended_year(), d.month().ordinal, d.day_of_month().0)
}
CalendarType::Persian => {
let d = iso_date.to_calendar(Persian);
(d.extended_year(), d.month().ordinal, d.day_of_month().0)
}
CalendarType::Ethiopian => {
let d = iso_date.to_calendar(Ethiopian::new());
(d.extended_year(), d.month().ordinal, d.day_of_month().0)
}
CalendarType::Gregorian => unreachable!(),
};
fmt = fmt
.replace("%Y", &cal_year.to_string())
.replace("%m", &format!("{cal_month:02}"))
.replace("%d", &format!("{cal_day:02}"))
.replace("%e", &format!("{cal_day:2}"));
}
// Format localized names using ICU DateTimeFormatter
let locale_prefs = locale.clone().into();
if fmt.contains("%B") {
if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::long()) {
fmt = fmt.replace("%B", &f.format(&iso_date).to_string());
}
}
if fmt.contains("%b") || fmt.contains("%h") {
if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::medium()) {
let month_abbrev = f.format(&iso_date).to_string();
fmt = fmt
.replace("%b", &month_abbrev)
.replace("%h", &month_abbrev);
}
}
if fmt.contains("%A") {
if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::long()) {
fmt = fmt.replace("%A", &f.format(&iso_date).to_string());
}
}
if fmt.contains("%a") {
if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::short()) {
fmt = fmt.replace("%a", &f.format(&iso_date).to_string());
}
}
fmt
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_localized_month_name_fallback() {
// This should work even if locale is not available
let name = get_localized_month_name(1, true);
// The function may return empty string if ICU fails, which is fine
// The caller (date.rs) will handle this by falling back to jiff
assert!(name.is_empty() || name.len() >= 3);
}
#[test]
fn test_calendar_type_detection() {
let thai_locale = icu_locale::locale!("th-TH");
let persian_locale = icu_locale::locale!("fa-IR");
let amharic_locale = icu_locale::locale!("am-ET");
let english_locale = icu_locale::locale!("en-US");
use icu_locale::locale;
assert_eq!(
get_locale_calendar_type(&thai_locale),
get_locale_calendar_type(&locale!("th-TH")),
CalendarType::Buddhist
);
assert_eq!(
get_locale_calendar_type(&persian_locale),
get_locale_calendar_type(&locale!("fa-IR")),
CalendarType::Persian
);
assert_eq!(
get_locale_calendar_type(&amharic_locale),
get_locale_calendar_type(&locale!("am-ET")),
CalendarType::Ethiopian
);
assert_eq!(
get_locale_calendar_type(&english_locale),
get_locale_calendar_type(&locale!("en-US")),
CalendarType::Gregorian
);
}
#[test]
fn test_era_year_conversion() {
let thai_locale = icu_locale::locale!("th-TH");
let persian_locale = icu_locale::locale!("fa-IR");
let amharic_locale = icu_locale::locale!("am-ET");
// Test Thai Buddhist calendar (2026 + 543 = 2569)
assert_eq!(get_era_year(2026, 6, 15, &thai_locale), Some(2569));
// Test Persian calendar (rough approximation)
assert_eq!(get_era_year(2026, 3, 22, &persian_locale), Some(1405));
assert_eq!(get_era_year(2026, 3, 19, &persian_locale), Some(1404));
// Test Ethiopian calendar (rough approximation)
assert_eq!(get_era_year(2026, 9, 12, &amharic_locale), Some(2019));
assert_eq!(get_era_year(2026, 9, 10, &amharic_locale), Some(2018));
}
}
+101 -1
View File
@@ -3,7 +3,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//
// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes
// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado
use std::cmp::Ordering;
@@ -1866,3 +1866,103 @@ fn test_date_thai_locale_solar_calendar() {
let rfc_output = rfc_result.stdout_str();
assert!(rfc_output.starts_with(&current_year.to_string()));
}
fn check_date(locale: &str, date: &str, fmt: &str, expected: &str) {
let actual = new_ucmd!()
.env("LC_ALL", locale)
.arg("-d")
.arg(date)
.arg(fmt)
.succeeds()
.stdout_str()
.trim()
.to_string();
assert_eq!(actual, expected, "LC_ALL={locale} date -d '{date}' '{fmt}'");
}
#[test]
#[cfg(unix)]
fn test_locale_calendar_conversions() {
// Persian (Solar Hijri) - Nowruz is March 20/21
for (d, e) in [
("2026-01-01", "1404-10-11"),
("2026-01-26", "1404-11-06"),
("2026-03-20", "1404-12-29"),
("2026-03-21", "1405-01-01"),
("2026-03-22", "1405-01-02"),
("2026-06-15", "1405-03-25"),
("2026-12-31", "1405-10-10"),
("2025-03-20", "1403-12-30"),
("2025-03-21", "1404-01-01"),
("2024-03-19", "1402-12-29"),
("2024-03-20", "1403-01-01"),
("2000-03-20", "1379-01-01"),
] {
check_date("fa_IR.UTF-8", d, "+%Y-%m-%d", e);
}
// Thai Buddhist (year + 543, same month/day)
for (d, e) in [
("2026-01-01", "2569-01-01"),
("2026-01-26", "2569-01-26"),
("2026-06-15", "2569-06-15"),
("2026-12-31", "2569-12-31"),
("2025-01-01", "2568-01-01"),
("2024-02-29", "2567-02-29"),
("2000-01-01", "2543-01-01"),
("1970-01-01", "2513-01-01"),
] {
check_date("th_TH.UTF-8", d, "+%Y-%m-%d", e);
}
// Ethiopian (13 months, New Year on Sept 11)
for (d, e) in [
("2026-01-01", "2018-04-23"),
("2026-01-26", "2018-05-18"),
("2026-09-10", "2018-13-05"),
("2026-09-11", "2019-01-01"),
("2026-09-12", "2019-01-02"),
("2026-12-31", "2019-04-22"),
("2025-09-11", "2018-01-01"),
("2025-09-10", "2017-13-05"),
("2000-09-11", "1993-01-01"),
] {
check_date("am_ET.UTF-8", d, "+%Y-%m-%d", e);
}
}
#[test]
#[cfg(unix)]
fn test_locale_month_names() {
// %B full month names: Jan, Jun, Dec for each locale
for (loc, jan, jun, dec) in [
("fr_FR.UTF-8", "janvier", "juin", "décembre"),
("de_DE.UTF-8", "Januar", "Juni", "Dezember"),
("es_ES.UTF-8", "enero", "junio", "diciembre"),
("it_IT.UTF-8", "gennaio", "giugno", "dicembre"),
("pt_BR.UTF-8", "janeiro", "junho", "dezembro"),
("ja_JP.UTF-8", "1月", "6月", "12月"),
("zh_CN.UTF-8", "一月", "六月", "十二月"),
] {
check_date(loc, "2026-01-15", "+%B", jan);
check_date(loc, "2026-06-15", "+%B", jun);
check_date(loc, "2026-12-15", "+%B", dec);
}
}
#[test]
#[cfg(unix)]
fn test_locale_day_names() {
// %A full day names: Mon (26th), Sun (25th), Sat (24th) Jan 2026
for (loc, mon, sun, sat) in [
("fr_FR.UTF-8", "lundi", "dimanche", "samedi"),
("de_DE.UTF-8", "Montag", "Sonntag", "Samstag"),
("es_ES.UTF-8", "lunes", "domingo", "sábado"),
("ja_JP.UTF-8", "月曜日", "日曜日", "土曜日"),
("zh_CN.UTF-8", "星期一", "星期日", "星期六"),
] {
check_date(loc, "2026-01-26", "+%A", mon);
check_date(loc, "2026-01-25", "+%A", sun);
check_date(loc, "2026-01-24", "+%A", sat);
}
}