#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CurrencyPosition {
Prefix,
Suffix,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct Locale {
pub tag: String,
pub decimal_mark: String,
pub big_mark: String,
pub min_grouping_digits: usize,
pub currency_symbol: String,
pub currency_position: CurrencyPosition,
pub currency_spacing: String,
pub percent_suffix: String,
pub months_short: [String; 12],
pub months_long: [String; 12],
pub days_short: [String; 7],
pub days_long: [String; 7],
pub date_format: String,
pub month_format: String,
pub time_format: String,
}
fn strings<const N: usize>(names: [&str; N]) -> [String; N] {
names.map(str::to_string)
}
const EN_MONTHS_SHORT: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const EN_MONTHS_LONG: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const EN_DAYS_SHORT: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const EN_DAYS_LONG: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
impl Locale {
pub fn en_us() -> Self {
Locale {
tag: "en-US".into(),
decimal_mark: ".".into(),
big_mark: ",".into(),
min_grouping_digits: 1,
currency_symbol: "$".into(),
currency_position: CurrencyPosition::Prefix,
currency_spacing: String::new(),
percent_suffix: "%".into(),
months_short: strings(EN_MONTHS_SHORT),
months_long: strings(EN_MONTHS_LONG),
days_short: strings(EN_DAYS_SHORT),
days_long: strings(EN_DAYS_LONG),
date_format: "%b %-d, %Y".into(),
month_format: "%b %Y".into(),
time_format: "%H:%M".into(),
}
}
pub fn en_gb() -> Self {
Locale {
tag: "en-GB".into(),
currency_symbol: "£".into(),
date_format: "%-d %b %Y".into(),
..Self::en_us()
}
}
pub fn de_de() -> Self {
Locale {
tag: "de-DE".into(),
decimal_mark: ",".into(),
big_mark: ".".into(),
min_grouping_digits: 1,
currency_symbol: "€".into(),
currency_position: CurrencyPosition::Suffix,
currency_spacing: " ".into(),
percent_suffix: " %".into(),
months_short: strings([
"Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.",
"Nov.", "Dez.",
]),
months_long: strings([
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
]),
days_short: strings(["So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."]),
days_long: strings([
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
]),
date_format: "%-d. %b %Y".into(),
month_format: "%b %Y".into(),
time_format: "%H:%M".into(),
}
}
pub fn fr_fr() -> Self {
Locale {
tag: "fr-FR".into(),
decimal_mark: ",".into(),
big_mark: " ".into(),
min_grouping_digits: 1,
currency_symbol: "€".into(),
currency_position: CurrencyPosition::Suffix,
currency_spacing: " ".into(),
percent_suffix: " %".into(),
months_short: strings([
"janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.",
"nov.", "déc.",
]),
months_long: strings([
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]),
days_short: strings(["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."]),
days_long: strings([
"dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi",
]),
date_format: "%-d %b %Y".into(),
month_format: "%b %Y".into(),
time_format: "%H:%M".into(),
}
}
pub fn try_from_tag(tag: &str) -> Option<Self> {
let t = tag
.trim()
.split(['.', '@'])
.next()
.unwrap_or("")
.replace('_', "-")
.to_ascii_lowercase();
let mut parts = t.split('-');
let lang = parts.next().unwrap_or("");
let region = parts.find(|p| p.len() != 4).unwrap_or("");
match (lang, region) {
("de", _) => Some(Self::de_de()),
("fr", _) => Some(Self::fr_fr()),
("en", "gb" | "uk") => Some(Self::en_gb()),
("en", _) | ("c", "") | ("posix", "") => Some(Self::en_us()),
_ => None,
}
}
pub fn from_tag(tag: &str) -> Self {
Self::try_from_tag(tag).unwrap_or_else(Self::en_us)
}
pub fn with_decimal_mark(mut self, mark: &str) -> Self {
self.decimal_mark = mark.to_string();
self
}
pub fn with_big_mark(mut self, mark: &str) -> Self {
self.big_mark = mark.to_string();
self
}
pub fn with_min_grouping_digits(mut self, digits: usize) -> Self {
self.min_grouping_digits = digits;
self
}
pub fn with_currency(mut self, symbol: &str) -> Self {
self.currency_symbol = symbol.to_string();
self
}
pub fn currency_affixes(&self, symbol: &str) -> (String, String) {
match self.currency_position {
CurrencyPosition::Prefix => {
(format!("{symbol}{}", self.currency_spacing), String::new())
}
CurrencyPosition::Suffix => {
(String::new(), format!("{}{symbol}", self.currency_spacing))
}
}
}
pub fn localize_number(&self, s: &str) -> String {
localize_number(
s,
&self.decimal_mark,
&self.big_mark,
self.min_grouping_digits,
)
}
pub fn parse_number(&self, s: &str) -> Option<f64> {
let mut t = s.trim().to_string();
if !self.big_mark.is_empty() {
t = t.replace(&self.big_mark, "");
}
if self.decimal_mark != "." {
t = t.replace(&self.decimal_mark, ".");
}
t.parse().ok()
}
pub fn format_number(&self, v: f64) -> String {
crate::scale::util::format_number_locale(v, self)
}
pub fn strftime(&self, epoch_secs: i64, fmt: &str) -> String {
crate::scale::datetime::strftime_locale(epoch_secs as f64, fmt, Some(self))
}
pub fn format_datetime(&self, epoch_secs: i64) -> String {
let fmt = if epoch_secs.rem_euclid(86_400) == 0 {
self.date_format.clone()
} else {
format!("{} {}", self.date_format, self.time_format)
};
self.strftime(epoch_secs, &fmt)
}
}
impl Default for Locale {
fn default() -> Self {
Self::en_us()
}
}
impl std::str::FromStr for Locale {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_tag(s))
}
}
pub(crate) fn localize_number(
s: &str,
decimal_mark: &str,
big_mark: &str,
min_grouping_digits: usize,
) -> String {
let (sign, rest) = match s.strip_prefix('-') {
Some(r) => ("-", r),
None => ("", s),
};
let (int_part, dec_part) = match rest.split_once('.') {
Some((i, d)) => (i, Some(d)),
None => (rest, None),
};
let digits: String = int_part.chars().filter(|&c| c != ',').collect();
let is_number = !digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit())
&& int_part.chars().all(|c| c.is_ascii_digit() || c == ',')
&& dec_part.is_none_or(|d| d.chars().all(|c| c.is_ascii_digit()));
if !is_number {
return s.to_string();
}
let n = digits.len();
let mut out = String::with_capacity(s.len() + n / 3 * big_mark.len());
out.push_str(sign);
let group = !big_mark.is_empty() && n >= 3 + min_grouping_digits.max(1);
for (i, ch) in digits.chars().enumerate() {
if group && i > 0 && (n - i).is_multiple_of(3) {
out.push_str(big_mark);
}
out.push(ch);
}
if let Some(d) = dec_part {
out.push_str(decimal_mark);
out.push_str(d);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn localize_regroups_and_swaps_marks() {
let de = Locale::de_de();
assert_eq!(de.localize_number("1234.5"), "1.234,5");
assert_eq!(de.localize_number("-1,234,567.25"), "-1.234.567,25");
assert_eq!(de.localize_number("999"), "999");
assert_eq!(de.localize_number("0.5"), "0,5");
assert_eq!(de.localize_number("NaN"), "NaN");
assert_eq!(de.localize_number("-inf"), "-inf");
assert_eq!(de.localize_number(""), "");
let fr = Locale::fr_fr();
assert_eq!(fr.localize_number("1234567.5"), "1 234 567,5");
let no_group = Locale::de_de().with_big_mark("");
assert_eq!(no_group.localize_number("1,234.5"), "1234,5");
let min2 = Locale::de_de().with_min_grouping_digits(2);
assert_eq!(min2.localize_number("2020"), "2020");
assert_eq!(min2.localize_number("20200"), "20.200");
}
#[test]
fn parse_number_inverts_localize() {
let de = Locale::de_de();
assert_eq!(de.parse_number("1.234,5"), Some(1234.5));
assert_eq!(de.parse_number("-0,25"), Some(-0.25));
assert_eq!(Locale::fr_fr().parse_number("1 000"), Some(1000.0));
assert_eq!(Locale::en_us().parse_number("1,000.5"), Some(1000.5));
}
#[test]
fn tags_resolve_to_presets() {
for (tag, want) in [
("de-DE", "de-DE"),
("de", "de-DE"),
("DE_at", "de-DE"),
("de_DE.UTF-8", "de-DE"),
("de_DE@euro", "de-DE"),
("fr-FR", "fr-FR"),
("fr-CA", "fr-FR"),
("en-GB", "en-GB"),
("en_gb", "en-GB"),
("en-Latn-GB", "en-GB"),
("en-US", "en-US"),
("en", "en-US"),
("C", "en-US"),
("", "en-US"),
("ja-JP", "en-US"),
] {
assert_eq!(Locale::from_tag(tag).tag, want, "{tag:?}");
}
assert!(Locale::try_from_tag("ja-JP").is_none());
assert!(Locale::try_from_tag("").is_none());
assert_eq!("de-DE".parse::<Locale>().unwrap(), Locale::de_de());
assert_eq!(Locale::default(), Locale::en_us());
}
#[test]
fn currency_affixes_follow_placement() {
assert_eq!(
Locale::en_us().currency_affixes("$"),
("$".into(), String::new())
);
assert_eq!(
Locale::de_de().currency_affixes("€"),
(String::new(), " €".into())
);
assert_eq!(
Locale::en_gb().currency_affixes("£"),
("£".into(), String::new())
);
}
#[test]
fn dates_per_locale() {
let t = 1_792_022_400; assert_eq!(Locale::de_de().format_datetime(t), "15. Okt. 2026");
assert_eq!(Locale::en_us().format_datetime(t), "Oct 15, 2026");
assert_eq!(Locale::en_gb().format_datetime(t), "15 Oct 2026");
assert_eq!(Locale::fr_fr().format_datetime(t), "15 oct. 2026");
assert_eq!(
Locale::de_de().format_datetime(t + 9 * 3600 + 30 * 60),
"15. Okt. 2026 09:30"
);
assert_eq!(
Locale::de_de().strftime(t, "%A, %-d. %B %Y"),
"Donnerstag, 15. Oktober 2026"
);
assert_eq!(Locale::fr_fr().strftime(t, "%a %-d %B"), "jeu. 15 octobre");
assert_eq!(Locale::en_us().strftime(t, "%a %A"), "Thu Thursday");
assert_eq!(Locale::de_de().strftime(t - 86_400 * 200, "%B"), "März");
}
}