Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .vscode/cspell.dictionaries/jargon.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,8 @@ ENOTSUP
enotsup
SETFL
tmpfs

Hijri
Nowruz
charmap
hijri
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/uu/date/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 39 additions & 16 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
133 changes: 133 additions & 0 deletions src/uucore/src/lib/features/i18n/datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this function isn't really correct since the days in a month is different in these calendars, it can give you the correct result for years, but this is still one of those examples where it would be better to directly use Jiff ICU instead of doing this conversion ourselves.

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::*;
Expand All @@ -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));
}
}
Loading
Loading