Skip to content

Commit c3a75e5

Browse files
committed
date: add locale-aware calendar support for era years
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. - Add CalendarType enum with Buddhist (+543), Persian (-621/-622), Ethiopian (-7/-8) year offsets - Implement get_era_year() with input validation and efficient calendar conversion - Extend ICU formatting to handle %Y era year replacement with collision-safe NULL-byte placeholders - Add comprehensive test coverage for th_TH.UTF-8, fa_IR.UTF-8, and am_ET.UTF-8 locales - Enable i18n-datetime feature by default with icu_locale dependency - Fix calendar conversion logic for proper year transitions at Nowruz and Ethiopian New Year - Remove unused helper functions for cleaner codebase
1 parent 8219153 commit c3a75e5

File tree

7 files changed

+422
-17
lines changed

7 files changed

+422
-17
lines changed

.vscode/cspell.dictionaries/jargon.wordlist.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,8 @@ ENOTSUP
224224
enotsup
225225
SETFL
226226
tmpfs
227+
228+
Hijri
229+
Nowruz
230+
charmap
231+
hijri

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

fuzz/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/uu/date/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ workspace = true
1919
path = "src/date.rs"
2020

2121
[features]
22-
i18n-datetime = ["uucore/i18n-datetime", "icu_calendar"]
22+
default = ["i18n-datetime"]
23+
i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale"]
2324

2425
[dependencies]
2526
clap = { workspace = true }
2627
fluent = { workspace = true }
2728
icu_calendar = { workspace = true, optional = true }
29+
icu_locale = { workspace = true, optional = true }
2830
jiff = { workspace = true, features = [
2931
"tzdb-bundle-platform",
3032
"tzdb-zoneinfo",

src/uu/date/src/date.rs

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ use std::sync::OnceLock;
2020
use uucore::display::Quotable;
2121
use uucore::error::FromIo;
2222
use uucore::error::{UResult, USimpleError};
23+
#[cfg(feature = "i18n-datetime")]
2324
use uucore::i18n::datetime::{
24-
get_localized_day_name, get_localized_month_name, should_use_icu_locale,
25+
get_era_year, get_localized_day_name, get_localized_month_name, get_time_locale,
26+
should_use_icu_locale,
2527
};
2628
use uucore::translate;
2729
use uucore::{format_usage, show};
@@ -618,14 +620,14 @@ fn format_date_with_locale_aware_months(
618620
format_string: &str,
619621
config: &Config<PosixCustom>,
620622
) -> Result<String, jiff::Error> {
621-
// Only use ICU for non-default locales and when format string contains month or day specifiers
622-
let use_icu = should_use_icu_locale();
623-
623+
// Only use ICU for non-English locales and when format string contains month, day, or era year specifiers
624624
if (format_string.contains("%B")
625625
|| format_string.contains("%b")
626626
|| format_string.contains("%A")
627-
|| format_string.contains("%a"))
628-
&& use_icu
627+
|| format_string.contains("%a")
628+
|| format_string.contains("%Y")
629+
|| format_string.contains("%Ey"))
630+
&& should_use_icu_locale()
629631
{
630632
let broken_down = BrokenDownTime::from(date);
631633
// Get localized month names if needed
@@ -665,37 +667,58 @@ fn format_date_with_locale_aware_months(
665667
(String::new(), String::new())
666668
};
667669

668-
// Replace format specifiers with placeholders for successful ICU translations only
670+
// Get era year if needed
671+
let era_year = if format_string.contains("%Y") || format_string.contains("%Ey") {
672+
if let (Some(year), Some(month), Some(day)) =
673+
(broken_down.year(), broken_down.month(), broken_down.day())
674+
{
675+
let (locale, _encoding) = get_time_locale();
676+
get_era_year(year.into(), month as u8, day as u8, locale)
677+
} else {
678+
None
679+
}
680+
} else {
681+
None
682+
};
683+
684+
// Replace format specifiers with NULL-byte placeholders for successful ICU translations only
685+
// Use NULL bytes to avoid collision with user format strings
669686
let mut temp_format = format_string.to_string();
670687
if !full_month.is_empty() {
671-
temp_format = temp_format.replace("%B", "<<<FULL_MONTH>>>");
688+
temp_format = temp_format.replace("%B", "\0FULL_MONTH\0");
672689
}
673690
if !abbrev_month.is_empty() {
674-
temp_format = temp_format.replace("%b", "<<<ABBREV_MONTH>>>");
691+
temp_format = temp_format.replace("%b", "\0ABBREV_MONTH\0");
675692
}
676693
if !full_day.is_empty() {
677-
temp_format = temp_format.replace("%A", "<<<FULL_DAY>>>");
694+
temp_format = temp_format.replace("%A", "\0FULL_DAY\0");
678695
}
679696
if !abbrev_day.is_empty() {
680-
temp_format = temp_format.replace("%a", "<<<ABBREV_DAY>>>");
697+
temp_format = temp_format.replace("%a", "\0ABBREV_DAY\0");
698+
}
699+
if era_year.is_some() {
700+
temp_format = temp_format.replace("%Y", "\0ERA_YEAR\0");
681701
}
682702

683703
// Format with the temporary string
684704
let temp_result = broken_down.to_string_with_config(config, &temp_format)?;
685705

686-
// Replace placeholders with localized names
706+
// Replace NULL-byte placeholders with localized names
687707
let mut final_result = temp_result;
688708
if !full_month.is_empty() {
689-
final_result = final_result.replace("<<<FULL_MONTH>>>", &full_month);
709+
final_result = final_result.replace("\0FULL_MONTH\0", &full_month);
690710
}
691711
if !abbrev_month.is_empty() {
692-
final_result = final_result.replace("<<<ABBREV_MONTH>>>", &abbrev_month);
712+
final_result = final_result.replace("\0ABBREV_MONTH\0", &abbrev_month);
693713
}
694714
if !full_day.is_empty() {
695-
final_result = final_result.replace("<<<FULL_DAY>>>", &full_day);
715+
final_result = final_result.replace("\0FULL_DAY\0", &full_day);
696716
}
697717
if !abbrev_day.is_empty() {
698-
final_result = final_result.replace("<<<ABBREV_DAY>>>", &abbrev_day);
718+
final_result = final_result.replace("\0ABBREV_DAY\0", &abbrev_day);
719+
}
720+
if let Some(era_year_val) = era_year {
721+
final_result = final_result.replace("\0ERA_YEAR\0", &era_year_val.to_string());
699722
}
700723

701724
return Ok(final_result);

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,96 @@ pub fn get_localized_day_name(year: i32, month: u8, day: u8, full: bool) -> Stri
116116
formatted.trim().to_string()
117117
}
118118

119+
/// Determine the appropriate calendar system for a given locale
120+
pub fn get_locale_calendar_type(locale: &Locale) -> CalendarType {
121+
let locale_str = locale.to_string();
122+
123+
match locale_str.as_str() {
124+
// Thai locales use Buddhist calendar
125+
s if s.starts_with("th") => CalendarType::Buddhist,
126+
// Persian/Farsi locales use Persian calendar (Solar Hijri)
127+
s if s.starts_with("fa") => CalendarType::Persian,
128+
// Amharic (Ethiopian) locales use Ethiopian calendar
129+
s if s.starts_with("am") => CalendarType::Ethiopian,
130+
// Default to Gregorian for all other locales
131+
_ => CalendarType::Gregorian,
132+
}
133+
}
134+
135+
/// Calendar types supported for locale-aware formatting
136+
#[derive(Debug, Clone, PartialEq)]
137+
pub enum CalendarType {
138+
/// Gregorian calendar (used by most locales)
139+
Gregorian,
140+
/// Buddhist calendar (Thai locales) - adds 543 years to Gregorian year
141+
Buddhist,
142+
/// Persian Solar Hijri calendar (Persian/Farsi locales) - subtracts 621/622 years
143+
Persian,
144+
/// Ethiopian calendar (Amharic locales) - subtracts 7/8 years
145+
Ethiopian,
146+
}
147+
148+
/// Convert a Gregorian date to the appropriate calendar system for a locale
149+
///
150+
/// # Arguments
151+
/// * `year` - Gregorian year
152+
/// * `month` - Month (1-12)
153+
/// * `day` - Day (1-31)
154+
/// * `calendar_type` - Target calendar system
155+
///
156+
/// # Returns
157+
/// * `Some((era_year, month, day))` - Date in target calendar system
158+
/// * `None` - If conversion fails
159+
pub fn convert_date_to_locale_calendar(
160+
year: i32,
161+
month: u8,
162+
day: u8,
163+
calendar_type: &CalendarType,
164+
) -> Option<(i32, u8, u8)> {
165+
match calendar_type {
166+
CalendarType::Gregorian => Some((year, month, day)),
167+
CalendarType::Buddhist => {
168+
// Buddhist calendar: Gregorian year + 543
169+
Some((year + 543, month, day))
170+
}
171+
CalendarType::Persian => {
172+
// Persian calendar conversion (Solar Hijri)
173+
// March 21 (Nowruz) is roughly the start of the Persian year
174+
let persian_year = if month > 3 || (month == 3 && day >= 21) {
175+
year - 621 // After March 21
176+
} else {
177+
year - 622 // Before March 21
178+
};
179+
Some((persian_year, month, day))
180+
}
181+
CalendarType::Ethiopian => {
182+
// Ethiopian calendar conversion
183+
// September 11/12 is roughly the start of the Ethiopian year
184+
let ethiopian_year = if month > 9 || (month == 9 && day >= 11) {
185+
year - 7 // After September 11
186+
} else {
187+
year - 8 // Before September 11
188+
};
189+
Some((ethiopian_year, month, day))
190+
}
191+
}
192+
}
193+
194+
/// Get the era year for a given date and locale
195+
pub fn get_era_year(year: i32, month: u8, day: u8, locale: &Locale) -> Option<i32> {
196+
// Validate input date
197+
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
198+
return None;
199+
}
200+
201+
let calendar_type = get_locale_calendar_type(locale);
202+
match calendar_type {
203+
CalendarType::Gregorian => None,
204+
_ => convert_date_to_locale_calendar(year, month, day, &calendar_type)
205+
.map(|(era_year, _, _)| era_year),
206+
}
207+
}
208+
119209
#[cfg(test)]
120210
mod tests {
121211
use super::*;
@@ -128,4 +218,47 @@ mod tests {
128218
// The caller (date.rs) will handle this by falling back to jiff
129219
assert!(name.is_empty() || name.len() >= 3);
130220
}
221+
222+
#[test]
223+
fn test_calendar_type_detection() {
224+
let thai_locale = icu_locale::locale!("th-TH");
225+
let persian_locale = icu_locale::locale!("fa-IR");
226+
let amharic_locale = icu_locale::locale!("am-ET");
227+
let english_locale = icu_locale::locale!("en-US");
228+
229+
assert_eq!(
230+
get_locale_calendar_type(&thai_locale),
231+
CalendarType::Buddhist
232+
);
233+
assert_eq!(
234+
get_locale_calendar_type(&persian_locale),
235+
CalendarType::Persian
236+
);
237+
assert_eq!(
238+
get_locale_calendar_type(&amharic_locale),
239+
CalendarType::Ethiopian
240+
);
241+
assert_eq!(
242+
get_locale_calendar_type(&english_locale),
243+
CalendarType::Gregorian
244+
);
245+
}
246+
247+
#[test]
248+
fn test_era_year_conversion() {
249+
let thai_locale = icu_locale::locale!("th-TH");
250+
let persian_locale = icu_locale::locale!("fa-IR");
251+
let amharic_locale = icu_locale::locale!("am-ET");
252+
253+
// Test Thai Buddhist calendar (2026 + 543 = 2569)
254+
assert_eq!(get_era_year(2026, 6, 15, &thai_locale), Some(2569));
255+
256+
// Test Persian calendar (rough approximation)
257+
assert_eq!(get_era_year(2026, 3, 22, &persian_locale), Some(1405));
258+
assert_eq!(get_era_year(2026, 3, 19, &persian_locale), Some(1404));
259+
260+
// Test Ethiopian calendar (rough approximation)
261+
assert_eq!(get_era_year(2026, 9, 12, &amharic_locale), Some(2019));
262+
assert_eq!(get_era_year(2026, 9, 10, &amharic_locale), Some(2018));
263+
}
131264
}

0 commit comments

Comments
 (0)