Skip to content

Commit 1684817

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.
1 parent 8219153 commit 1684817

File tree

7 files changed

+478
-7
lines changed

7 files changed

+478
-7
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: 28 additions & 6 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,6 +667,20 @@ fn format_date_with_locale_aware_months(
665667
(String::new(), String::new())
666668
};
667669

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+
668684
// Replace format specifiers with placeholders for successful ICU translations only
669685
let mut temp_format = format_string.to_string();
670686
if !full_month.is_empty() {
@@ -679,6 +695,9 @@ fn format_date_with_locale_aware_months(
679695
if !abbrev_day.is_empty() {
680696
temp_format = temp_format.replace("%a", "<<<ABBREV_DAY>>>");
681697
}
698+
if era_year.is_some() {
699+
temp_format = temp_format.replace("%Y", "<<<ERA_YEAR>>>");
700+
}
682701

683702
// Format with the temporary string
684703
let temp_result = broken_down.to_string_with_config(config, &temp_format)?;
@@ -697,6 +716,9 @@ fn format_date_with_locale_aware_months(
697716
if !abbrev_day.is_empty() {
698717
final_result = final_result.replace("<<<ABBREV_DAY>>>", &abbrev_day);
699718
}
719+
if let Some(era_year_val) = era_year {
720+
final_result = final_result.replace("<<<ERA_YEAR>>>", &era_year_val.to_string());
721+
}
700722

701723
return Ok(final_result);
702724
}

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

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,163 @@ 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+
// This is complex - for now, approximate conversion
174+
// March 21 (Nowruz) is roughly the start of the Persian year
175+
let persian_year = if month >= 3 && day >= 21 {
176+
year - 621 // After March 21
177+
} else {
178+
year - 622 // Before March 21
179+
};
180+
Some((persian_year, month, day))
181+
}
182+
CalendarType::Ethiopian => {
183+
// Ethiopian calendar conversion
184+
// September 11/12 is roughly the start of the Ethiopian year
185+
let ethiopian_year = if month >= 9 && day >= 11 {
186+
year - 7 // After September 11
187+
} else {
188+
year - 8 // Before September 11
189+
};
190+
Some((ethiopian_year, month, day))
191+
}
192+
}
193+
}
194+
195+
/// Get the era year for a given date and locale
196+
pub fn get_era_year(year: i32, month: u8, day: u8, locale: &Locale) -> Option<i32> {
197+
// Validate input date
198+
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
199+
return None;
200+
}
201+
202+
let calendar_type = get_locale_calendar_type(locale);
203+
match calendar_type {
204+
CalendarType::Gregorian => None,
205+
_ => convert_date_to_locale_calendar(year, month, day, &calendar_type)
206+
.map(|(era_year, _, _)| era_year),
207+
}
208+
}
209+
210+
/// Get era-specific month names for a given calendar type and month
211+
pub fn get_era_month_name(
212+
month: u8,
213+
full: bool,
214+
calendar_type: &CalendarType,
215+
locale: &Locale,
216+
) -> Option<String> {
217+
match calendar_type {
218+
CalendarType::Buddhist => {
219+
// Get Thai Buddhist month names
220+
get_localized_month_name_for_calendar(month, full, locale, calendar_type)
221+
}
222+
CalendarType::Persian => {
223+
// Get Persian Solar Hijri month names
224+
get_localized_month_name_for_calendar(month, full, locale, calendar_type)
225+
}
226+
CalendarType::Ethiopian => {
227+
// Get Ethiopian month names
228+
get_localized_month_name_for_calendar(month, full, locale, calendar_type)
229+
}
230+
CalendarType::Gregorian => {
231+
// Use standard Gregorian month names
232+
None
233+
}
234+
}
235+
}
236+
237+
/// Get localized month names for specific calendar systems
238+
fn get_localized_month_name_for_calendar(
239+
month: u8,
240+
full: bool,
241+
_locale: &Locale,
242+
calendar_type: &CalendarType,
243+
) -> Option<String> {
244+
// For Thai Buddhist calendar - check if months ending in 31 days have "คม" suffix
245+
if calendar_type == &CalendarType::Buddhist && matches!(month, 1 | 3 | 5 | 7 | 8 | 10 | 12) {
246+
let month_name = get_localized_month_name(month, full);
247+
if !month_name.is_empty() {
248+
// Verify the month name ends with "คม" for 31-day months in Thai
249+
let suffix = "\u{0E04}\u{0E21}"; // "คม"
250+
if month_name.ends_with(suffix) {
251+
return Some(month_name);
252+
}
253+
}
254+
}
255+
256+
// For other calendar systems, try to get standard localized names
257+
let month_name = get_localized_month_name(month, full);
258+
if month_name.is_empty() {
259+
None
260+
} else {
261+
Some(month_name)
262+
}
263+
}
264+
265+
// Fix the match arm - Buddhist should be CalendarType::Buddhist
266+
impl CalendarType {
267+
/// Check if this calendar uses era-based years that differ from Gregorian
268+
pub fn uses_era(&self) -> bool {
269+
match self {
270+
Self::Gregorian => false,
271+
Self::Buddhist | Self::Persian | Self::Ethiopian => true,
272+
}
273+
}
274+
}
275+
119276
#[cfg(test)]
120277
mod tests {
121278
use super::*;
@@ -128,4 +285,47 @@ mod tests {
128285
// The caller (date.rs) will handle this by falling back to jiff
129286
assert!(name.is_empty() || name.len() >= 3);
130287
}
288+
289+
#[test]
290+
fn test_calendar_type_detection() {
291+
let thai_locale = icu_locale::locale!("th-TH");
292+
let persian_locale = icu_locale::locale!("fa-IR");
293+
let amharic_locale = icu_locale::locale!("am-ET");
294+
let english_locale = icu_locale::locale!("en-US");
295+
296+
assert_eq!(
297+
get_locale_calendar_type(&thai_locale),
298+
CalendarType::Buddhist
299+
);
300+
assert_eq!(
301+
get_locale_calendar_type(&persian_locale),
302+
CalendarType::Persian
303+
);
304+
assert_eq!(
305+
get_locale_calendar_type(&amharic_locale),
306+
CalendarType::Ethiopian
307+
);
308+
assert_eq!(
309+
get_locale_calendar_type(&english_locale),
310+
CalendarType::Gregorian
311+
);
312+
}
313+
314+
#[test]
315+
fn test_era_year_conversion() {
316+
let thai_locale = icu_locale::locale!("th-TH");
317+
let persian_locale = icu_locale::locale!("fa-IR");
318+
let amharic_locale = icu_locale::locale!("am-ET");
319+
320+
// Test Thai Buddhist calendar (2026 + 543 = 2569)
321+
assert_eq!(get_era_year(2026, 6, 15, &thai_locale), Some(2569));
322+
323+
// Test Persian calendar (rough approximation)
324+
assert_eq!(get_era_year(2026, 3, 22, &persian_locale), Some(1405));
325+
assert_eq!(get_era_year(2026, 3, 19, &persian_locale), Some(1404));
326+
327+
// Test Ethiopian calendar (rough approximation)
328+
assert_eq!(get_era_year(2026, 9, 12, &amharic_locale), Some(2019));
329+
assert_eq!(get_era_year(2026, 9, 10, &amharic_locale), Some(2018));
330+
}
131331
}

0 commit comments

Comments
 (0)