use std::{collections::HashMap, sync::Mutex};
use icu_calendar::{
Date,
types::Weekday,
week::{WeekInformation, WeekPreferences},
};
use icu_datetime::{
DateTimeFormatter, DateTimeFormatterPreferences, NoCalendarFormatter, fieldsets, input::Time,
preferences::HourCycle,
};
use icu_decimal::{
DecimalFormatter,
input::{Decimal, FloatPrecision},
};
use icu_experimental::dimension::{
currency::{
CurrencyType,
formatter::{CurrencyFormatter, CurrencyFormatterPreferences},
},
percent::formatter::PercentFormatter,
};
use icu_locale::Locale as IcuLocale;
use crate::Locale;
#[derive(Clone, Debug, thiserror::Error)]
pub enum FormatError {
#[error("formatting data is unavailable for locale `{locale}`: {detail}")]
Data {
locale: String,
detail: String,
},
#[error("invalid date {year:04}-{month:02}-{day:02}")]
InvalidDate {
year: i32,
month: u8,
day: u8,
},
#[error("invalid time {hour:02}:{minute:02}")]
InvalidTime {
hour: u8,
minute: u8,
},
#[error("invalid number or precision")]
InvalidNumber,
#[error("invalid ISO currency code `{0}`")]
InvalidCurrency(String),
}
pub struct LocaleFormatters {
locale_tag: String,
calendar: CalendarFormatters,
decimal: DecimalFormatter,
percent: PercentFormatter<DecimalFormatter>,
currency_preferences: CurrencyFormatterPreferences,
currencies: Mutex<HashMap<CurrencyType, CurrencyFormatter<DecimalFormatter>>>,
}
struct CalendarFormatters {
month_year: DateTimeFormatter<fieldsets::YM>,
date: DateTimeFormatter<fieldsets::YMD>,
long_date: DateTimeFormatter<fieldsets::YMDE>,
weekday: DateTimeFormatter<fieldsets::E>,
first_weekday: u8,
time: NoCalendarFormatter<fieldsets::T>,
twelve_hour_time: NoCalendarFormatter<fieldsets::T>,
}
impl CalendarFormatters {
fn new(locale: &IcuLocale, tag: &str) -> Result<Self, FormatError> {
let data = |error: icu_datetime::DateTimeFormatterLoadError| data_error(tag, error);
let time_preferences = DateTimeFormatterPreferences::from(locale);
let mut twelve_hour_preferences = time_preferences;
twelve_hour_preferences.hour_cycle = Some(HourCycle::Clock12);
Ok(Self {
month_year: DateTimeFormatter::try_new(locale.into(), fieldsets::YM::long())
.map_err(data)?,
date: DateTimeFormatter::try_new(locale.into(), fieldsets::YMD::medium())
.map_err(data)?,
long_date: DateTimeFormatter::try_new(locale.into(), fieldsets::YMDE::long())
.map_err(data)?,
weekday: DateTimeFormatter::try_new(locale.into(), fieldsets::E::short())
.map_err(data)?,
first_weekday: weekday_number(
WeekInformation::try_new(WeekPreferences::from(locale))
.map_err(|error| data_error(tag, error))?
.first_weekday,
),
time: NoCalendarFormatter::try_new(time_preferences, fieldsets::T::hm())
.map_err(data)?,
twelve_hour_time: NoCalendarFormatter::try_new(
twelve_hour_preferences,
fieldsets::T::hm(),
)
.map_err(data)?,
})
}
}
impl LocaleFormatters {
pub fn new(locale: &Locale) -> Result<Self, FormatError> {
let locale_tag = locale.to_string();
let icu_locale = locale_tag
.parse::<IcuLocale>()
.map_err(|error| FormatError::Data {
locale: locale_tag.clone(),
detail: error.to_string(),
})?;
let calendar = CalendarFormatters::new(&icu_locale, &locale_tag)?;
let decimal = DecimalFormatter::try_new(icu_locale.clone().into(), Default::default())
.map_err(|error| data_error(&locale_tag, error))?;
let percent = PercentFormatter::try_new(icu_locale.clone().into(), Default::default())
.map_err(|error| data_error(&locale_tag, error))?;
let currency_preferences = CurrencyFormatterPreferences::from(icu_locale);
Ok(Self {
locale_tag,
calendar,
decimal,
percent,
currency_preferences,
currencies: Mutex::new(HashMap::new()),
})
}
pub fn format_year_month(&self, year: i32, month: u8) -> Result<String, FormatError> {
let date = self.date_input(year, month, 1)?;
Ok(self.calendar.month_year.format(&date).to_string())
}
pub fn format_date(&self, year: i32, month: u8, day: u8) -> Result<String, FormatError> {
let date = self.date_input(year, month, day)?;
Ok(self.calendar.date.format(&date).to_string())
}
pub fn format_long_date(&self, year: i32, month: u8, day: u8) -> Result<String, FormatError> {
let date = self.date_input(year, month, day)?;
Ok(self.calendar.long_date.format(&date).to_string())
}
pub fn format_weekday(&self, year: i32, month: u8, day: u8) -> Result<String, FormatError> {
let date = self.date_input(year, month, day)?;
Ok(self.calendar.weekday.format(&date).to_string())
}
pub fn first_weekday(&self) -> u8 {
self.calendar.first_weekday
}
pub fn format_time(&self, hour: u8, minute: u8) -> Result<String, FormatError> {
Ok(self
.calendar
.time
.format(&time_input(hour, minute)?)
.to_string())
}
pub fn uses_24_hour_clock(&self) -> bool {
let hours = |hour| {
time_input(hour, 0)
.map(|time| self.calendar.time.format(&time).to_string())
.map(|text| {
text.chars()
.filter(|char| char.is_numeric())
.collect::<String>()
})
};
hours(1).ok() != hours(13).ok()
}
pub fn day_periods(&self) -> Result<[String; 2], FormatError> {
let period = |hour| -> Result<String, FormatError> {
let text = self
.calendar
.twelve_hour_time
.format(&time_input(hour, 0)?)
.to_string();
Ok(text
.split(|char: char| char.is_numeric() || char == ':' || char == '.')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" "))
};
Ok([period(9)?, period(21)?])
}
pub fn format_decimal(&self, value: f64, fraction_digits: u8) -> Result<String, FormatError> {
let number = decimal(value, fraction_digits)?;
Ok(self.decimal.format(&number).to_string())
}
pub fn format_percent(&self, ratio: f64, fraction_digits: u8) -> Result<String, FormatError> {
let percent = decimal(ratio * 100.0, fraction_digits)?;
Ok(self.percent.format(&percent).to_string())
}
pub fn format_currency(&self, amount: f64, currency_code: &str) -> Result<String, FormatError> {
if currency_code.len() != 3 || !currency_code.bytes().all(|byte| byte.is_ascii_uppercase())
{
return Err(FormatError::InvalidCurrency(currency_code.to_owned()));
}
let code = CurrencyType::try_from_str(currency_code)
.map_err(|_| FormatError::InvalidCurrency(currency_code.to_owned()))?;
let amount = Decimal::try_from_f64(amount, FloatPrecision::RoundTrip)
.map_err(|_| FormatError::InvalidNumber)?;
let mut currencies = self.currencies.lock().map_err(|_| FormatError::Data {
locale: self.locale_tag.clone(),
detail: "currency formatter cache is unavailable".to_owned(),
})?;
let formatter = match currencies.entry(code) {
std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
std::collections::hash_map::Entry::Vacant(entry) => {
let formatter = CurrencyFormatter::<DecimalFormatter>::try_new_symbol(
self.currency_preferences,
code,
Default::default(),
)
.map_err(|error| data_error(&self.locale_tag, error))?;
entry.insert(formatter)
}
};
Ok(formatter.format_fixed_decimal(&amount).to_string())
}
fn date_input(
&self,
year: i32,
month: u8,
day: u8,
) -> Result<Date<icu_calendar::Gregorian>, FormatError> {
Date::try_new_gregorian(year, month, day).map_err(|_| FormatError::InvalidDate {
year,
month,
day,
})
}
}
fn time_input(hour: u8, minute: u8) -> Result<Time, FormatError> {
Time::try_new(hour, minute, 0, 0).map_err(|_| FormatError::InvalidTime { hour, minute })
}
fn weekday_number(weekday: Weekday) -> u8 {
match weekday {
Weekday::Monday => 1,
Weekday::Tuesday => 2,
Weekday::Wednesday => 3,
Weekday::Thursday => 4,
Weekday::Friday => 5,
Weekday::Saturday => 6,
Weekday::Sunday => 7,
}
}
fn decimal(value: f64, fraction_digits: u8) -> Result<Decimal, FormatError> {
if fraction_digits > 18 {
return Err(FormatError::InvalidNumber);
}
Decimal::try_from_f64(
value,
FloatPrecision::Magnitude(-i16::from(fraction_digits)),
)
.map_err(|_| FormatError::InvalidNumber)
}
fn data_error(locale: &str, error: impl std::fmt::Display) -> FormatError {
FormatError::Data {
locale: locale.to_owned(),
detail: error.to_string(),
}
}