date: add locale-aware calendar support for era years (#10473)

Implements era year calculation for Buddhist, Persian Solar Hijri, and Ethiopian calendars based on locale detection. The %Y format specifier now outputs era-appropriate years while maintaining Gregorian calendar for ISO-8601 and RFC-3339 formats for interoperability.
This commit is contained in:
Sylvestre Ledru
2026-01-26 09:42:26 -08:00
committed by GitHub
parent 2839f78b92
commit 6124866934
7 changed files with 422 additions and 17 deletions
+5
View File
@@ -227,3 +227,8 @@ ENOTSUP
enotsup
SETFL
tmpfs
Hijri
Nowruz
charmap
hijri
Generated
+1
View File
@@ -3345,6 +3345,7 @@ dependencies = [
"codspeed-divan-compat",
"fluent",
"icu_calendar",
"icu_locale",
"jiff",
"nix",
"parse_datetime",
+2
View File
@@ -1718,6 +1718,8 @@ version = "0.6.0"
dependencies = [
"clap",
"fluent",
"icu_calendar",
"icu_locale",
"jiff",
"nix",
"parse_datetime",
+3 -1
View File
@@ -19,12 +19,14 @@ workspace = true
path = "src/date.rs"
[features]
i18n-datetime = ["uucore/i18n-datetime", "icu_calendar"]
default = ["i18n-datetime"]
i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale"]
[dependencies]
clap = { workspace = true }
fluent = { workspace = true }
icu_calendar = { workspace = true, optional = true }
icu_locale = { workspace = true, optional = true }
jiff = { workspace = true, features = [
"tzdb-bundle-platform",
"tzdb-zoneinfo",
+39 -16
View File
@@ -20,8 +20,10 @@ use std::sync::OnceLock;
use uucore::display::Quotable;
use uucore::error::FromIo;
use uucore::error::{UResult, USimpleError};
#[cfg(feature = "i18n-datetime")]
use uucore::i18n::datetime::{
get_localized_day_name, get_localized_month_name, should_use_icu_locale,
get_era_year, get_localized_day_name, get_localized_month_name, get_time_locale,
should_use_icu_locale,
};
use uucore::translate;
use uucore::{format_usage, show};
@@ -618,14 +620,14 @@ fn format_date_with_locale_aware_months(
format_string: &str,
config: &Config<PosixCustom>,
) -> Result<String, jiff::Error> {
// Only use ICU for non-default locales and when format string contains month or day specifiers
let use_icu = should_use_icu_locale();
// 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"))
&& use_icu
|| 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
@@ -665,37 +667,58 @@ fn format_date_with_locale_aware_months(
(String::new(), String::new())
};
// Replace format specifiers with placeholders for successful ICU translations only
// 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", "<<<FULL_MONTH>>>");
temp_format = temp_format.replace("%B", "\0FULL_MONTH\0");
}
if !abbrev_month.is_empty() {
temp_format = temp_format.replace("%b", "<<<ABBREV_MONTH>>>");
temp_format = temp_format.replace("%b", "\0ABBREV_MONTH\0");
}
if !full_day.is_empty() {
temp_format = temp_format.replace("%A", "<<<FULL_DAY>>>");
temp_format = temp_format.replace("%A", "\0FULL_DAY\0");
}
if !abbrev_day.is_empty() {
temp_format = temp_format.replace("%a", "<<<ABBREV_DAY>>>");
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 placeholders with localized names
// Replace NULL-byte placeholders with localized names
let mut final_result = temp_result;
if !full_month.is_empty() {
final_result = final_result.replace("<<<FULL_MONTH>>>", &full_month);
final_result = final_result.replace("\0FULL_MONTH\0", &full_month);
}
if !abbrev_month.is_empty() {
final_result = final_result.replace("<<<ABBREV_MONTH>>>", &abbrev_month);
final_result = final_result.replace("\0ABBREV_MONTH\0", &abbrev_month);
}
if !full_day.is_empty() {
final_result = final_result.replace("<<<FULL_DAY>>>", &full_day);
final_result = final_result.replace("\0FULL_DAY\0", &full_day);
}
if !abbrev_day.is_empty() {
final_result = final_result.replace("<<<ABBREV_DAY>>>", &abbrev_day);
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);
@@ -116,6 +116,96 @@ pub fn get_localized_day_name(year: i32, month: u8, day: u8, full: bool) -> Stri
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();
match locale_str.as_str() {
// Thai locales use Buddhist calendar
s if s.starts_with("th") => CalendarType::Buddhist,
// Persian/Farsi locales use Persian calendar (Solar Hijri)
s if s.starts_with("fa") => CalendarType::Persian,
// Amharic (Ethiopian) locales use Ethiopian calendar
s if s.starts_with("am") => CalendarType::Ethiopian,
// Default to Gregorian for all other locales
_ => CalendarType::Gregorian,
}
}
/// Calendar types supported for locale-aware formatting
#[derive(Debug, Clone, PartialEq)]
pub enum CalendarType {
/// Gregorian calendar (used by most locales)
Gregorian,
/// Buddhist calendar (Thai locales) - adds 543 years to Gregorian year
Buddhist,
/// Persian Solar Hijri calendar (Persian/Farsi locales) - subtracts 621/622 years
Persian,
/// Ethiopian calendar (Amharic locales) - subtracts 7/8 years
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))
}
}
}
/// 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 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),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -128,4 +218,47 @@ mod tests {
// 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");
assert_eq!(
get_locale_calendar_type(&thai_locale),
CalendarType::Buddhist
);
assert_eq!(
get_locale_calendar_type(&persian_locale),
CalendarType::Persian
);
assert_eq!(
get_locale_calendar_type(&amharic_locale),
CalendarType::Ethiopian
);
assert_eq!(
get_locale_calendar_type(&english_locale),
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));
}
}
+239
View File
@@ -1627,3 +1627,242 @@ fn test_date_parenthesis_vs_other_special_chars() {
.stderr_contains("invalid date");
}
}
#[test]
#[cfg(unix)]
fn test_date_iranian_locale_solar_hijri_calendar() {
// Test Iranian locale uses Solar Hijri calendar
// Verify the Solar Hijri calendar is used in the Iranian locale
use std::process::Command;
// Check if Iranian locale is available
let locale_check = Command::new("locale")
.env("LC_ALL", "fa_IR.UTF-8")
.arg("charmap")
.output();
let locale_available = match locale_check {
Ok(output) => String::from_utf8_lossy(&output.stdout).trim() == "UTF-8",
Err(_) => false,
};
if !locale_available {
println!("Skipping Iranian locale test - fa_IR.UTF-8 locale not available");
return;
}
// Get current year in Gregorian calendar
let current_year: i32 = new_ucmd!()
.env("LC_ALL", "C")
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
// 03-19 and 03-22 of the same Gregorian year are in different years in the
// Solar Hijri calendar
let year_march_19: i32 = new_ucmd!()
.env("LC_ALL", "fa_IR.UTF-8")
.arg("-d")
.arg(format!("{current_year}-03-19"))
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
let year_march_22: i32 = new_ucmd!()
.env("LC_ALL", "fa_IR.UTF-8")
.arg("-d")
.arg(format!("{current_year}-03-22"))
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
// Years should differ by 1
assert_eq!(year_march_19, year_march_22 - 1);
// The difference between the Gregorian year is 621 or 622 years
assert_eq!(year_march_19, current_year - 622);
assert_eq!(year_march_22, current_year - 621);
// Check that --iso-8601 and --rfc-3339 use the Gregorian calendar
let iso_result = new_ucmd!()
.env("LC_ALL", "fa_IR.UTF-8")
.arg("--iso-8601=hours")
.succeeds();
let iso_output = iso_result.stdout_str();
assert!(iso_output.starts_with(&current_year.to_string()));
let rfc_result = new_ucmd!()
.env("LC_ALL", "fa_IR.UTF-8")
.arg("--rfc-3339=date")
.succeeds();
let rfc_output = rfc_result.stdout_str();
assert!(rfc_output.starts_with(&current_year.to_string()));
}
#[test]
#[cfg(unix)]
fn test_date_ethiopian_locale_calendar() {
// Test Ethiopian locale uses Ethiopian calendar
// Verify the Ethiopian calendar is used in the Ethiopian locale
use std::process::Command;
// Check if Ethiopian locale is available
let locale_check = Command::new("locale")
.env("LC_ALL", "am_ET.UTF-8")
.arg("charmap")
.output();
let locale_available = match locale_check {
Ok(output) => String::from_utf8_lossy(&output.stdout).trim() == "UTF-8",
Err(_) => false,
};
if !locale_available {
println!("Skipping Ethiopian locale test - am_ET.UTF-8 locale not available");
return;
}
// Get current year in Gregorian calendar
let current_year: i32 = new_ucmd!()
.env("LC_ALL", "C")
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
// 09-10 and 09-12 of the same Gregorian year are in different years in the
// Ethiopian calendar
let year_september_10: i32 = new_ucmd!()
.env("LC_ALL", "am_ET.UTF-8")
.arg("-d")
.arg(format!("{current_year}-09-10"))
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
let year_september_12: i32 = new_ucmd!()
.env("LC_ALL", "am_ET.UTF-8")
.arg("-d")
.arg(format!("{current_year}-09-12"))
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
// Years should differ by 1
assert_eq!(year_september_10, year_september_12 - 1);
// The difference between the Gregorian year is 7 or 8 years
assert_eq!(year_september_10, current_year - 8);
assert_eq!(year_september_12, current_year - 7);
// Check that --iso-8601 and --rfc-3339 use the Gregorian calendar
let iso_result = new_ucmd!()
.env("LC_ALL", "am_ET.UTF-8")
.arg("--iso-8601=hours")
.succeeds();
let iso_output = iso_result.stdout_str();
assert!(iso_output.starts_with(&current_year.to_string()));
let rfc_result = new_ucmd!()
.env("LC_ALL", "am_ET.UTF-8")
.arg("--rfc-3339=date")
.succeeds();
let rfc_output = rfc_result.stdout_str();
assert!(rfc_output.starts_with(&current_year.to_string()));
}
#[test]
#[cfg(unix)]
fn test_date_thai_locale_solar_calendar() {
// Test Thai locale uses Thai solar calendar
// Verify the Thai solar calendar is used with the Thai locale
use std::process::Command;
// Check if Thai locale is available
let locale_check = Command::new("locale")
.env("LC_ALL", "th_TH.UTF-8")
.arg("charmap")
.output();
let locale_available = match locale_check {
Ok(output) => String::from_utf8_lossy(&output.stdout).trim() == "UTF-8",
Err(_) => false,
};
if !locale_available {
println!("Skipping Thai locale test - th_TH.UTF-8 locale not available");
return;
}
// Get current year in Gregorian calendar
let current_year: i32 = new_ucmd!()
.env("LC_ALL", "C")
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
// Since 1941, the year in the Thai solar calendar is the Gregorian year plus 543
let thai_year: i32 = new_ucmd!()
.env("LC_ALL", "th_TH.UTF-8")
.arg("+%Y")
.succeeds()
.stdout_str()
.trim()
.parse()
.unwrap();
assert_eq!(thai_year, current_year + 543);
// All months that have 31 days have names that end with "คม" (Thai characters)
let days_31_suffix = "\u{0E04}\u{0E21}"; // "คม" in Unicode
for month in ["01", "03", "05", "07", "08", "10", "12"] {
let month_result = new_ucmd!()
.env("LC_ALL", "th_TH.UTF-8")
.arg("--date")
.arg(format!("{current_year}-{month}-01"))
.arg("+%B")
.succeeds();
let month_name = month_result.stdout_str();
assert!(
month_name.trim().ends_with(days_31_suffix),
"Month {month} should end with 'คม', got: {month_name}"
);
}
// Check that --iso-8601 and --rfc-3339 use the Gregorian calendar
let iso_result = new_ucmd!()
.env("LC_ALL", "th_TH.UTF-8")
.arg("--iso-8601=hours")
.succeeds();
let iso_output = iso_result.stdout_str();
assert!(iso_output.starts_with(&current_year.to_string()));
let rfc_result = new_ucmd!()
.env("LC_ALL", "th_TH.UTF-8")
.arg("--rfc-3339=date")
.succeeds();
let rfc_output = rfc_result.stdout_str();
assert!(rfc_output.starts_with(&current_year.to_string()));
}