use crate::clock_msgs::CATALOGUES;
#[derive(Clone, Debug)]
pub struct Era {
pub start: i64,
pub name: String,
pub year: i64,
}
#[derive(Clone, Debug)]
pub struct Catalog {
pub name: String,
pub months_full: Vec<String>,
pub months_abbrev: Vec<String>,
pub days_full: Vec<String>,
pub days_abbrev: Vec<String>,
pub am: String,
pub pm: String,
pub bce: String,
pub ce: String,
pub eras: Vec<Era>,
pub numerals: Vec<String>,
pub date_format: String,
pub time_format: String,
pub time_format_12: String,
pub time_format_24: String,
pub time_format_24_secs: String,
pub date_time_format: String,
pub locale_date_format: String,
pub locale_time_format: String,
pub locale_date_time_format: String,
pub locale_year_format: String,
pub gregorian_change_date: i64,
}
impl Default for Catalog {
fn default() -> Self {
Catalog {
name: String::new(),
months_full: words(
"January February March April May June \
July August September October November December",
),
months_abbrev: words("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec"),
days_full: words("Sunday Monday Tuesday Wednesday Thursday Friday Saturday"),
days_abbrev: words("Sun Mon Tue Wed Thu Fri Sat"),
am: "am".to_string(),
pm: "pm".to_string(),
bce: "B.C.E.".to_string(),
ce: "C.E.".to_string(),
eras: Vec::new(),
numerals: (0..100).map(|n| format!("{n:02}")).collect(),
date_format: "%m/%d/%Y".to_string(),
time_format: "%H:%M:%S".to_string(),
time_format_12: "%I:%M:%S %P".to_string(),
time_format_24: "%H:%M".to_string(),
time_format_24_secs: "%H:%M:%S".to_string(),
date_time_format: "%a %b %e %H:%M:%S %Y".to_string(),
locale_date_format: "%m/%d/%Y".to_string(),
locale_time_format: "%H:%M:%S".to_string(),
locale_date_time_format: "%a %b %e %H:%M:%S %Y".to_string(),
locale_year_format: "%EC%Ey".to_string(),
gregorian_change_date: 2299161,
}
}
}
fn words(text: &str) -> Vec<String> {
text.split_whitespace().map(str::to_string).collect()
}
const CHANGE_DATES: &[(&str, i64)] = &[
("it", 2299161),
("es", 2299161),
("pt", 2299161),
("pl", 2299161),
("fr", 2299227),
("fr_be", 2299238),
("nl_be", 2299238),
("de_at", 2299527),
("hu", 2301004),
("de_de", 2342032),
("nb", 2342032),
("nn", 2342032),
("no", 2342032),
("da", 2342032),
("nl", 2342165),
("fr_ch", 2361342),
("it_ch", 2361342),
("de_ch", 2361342),
("en", 2361222),
("sv", 2361390),
("ru", 2421639),
("ro", 2422063),
("el", 2423480),
];
pub fn preferences(locale: &str) -> Vec<String> {
let locale = locale.to_lowercase();
let mut result = vec![String::new()];
if locale.is_empty() {
return result;
}
let mut element = String::new();
for part in locale.split('_') {
if element.is_empty() {
element = part.to_string();
} else {
element = format!("{element}_{part}");
}
if !element.ends_with('_') {
result.insert(0, element.clone());
}
}
result
}
fn convert_locale(value: &str) -> Option<String> {
let (head, modifier) = match value.split_once('@') {
Some((head, modifier)) => (head, modifier),
None => (value, ""),
};
let head = head.split('.').next().unwrap_or("");
let (language, territory) = match head.split_once('_') {
Some((language, territory)) => (language, territory),
None => (head, ""),
};
if language.is_empty() {
return None;
}
let mut out = language.to_string();
if !territory.is_empty() {
out.push('_');
out.push_str(territory);
}
if !modifier.is_empty() {
out.push('_');
out.push_str(modifier);
}
Some(out)
}
pub fn system_locale() -> String {
for name in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(value) = std::env::var(name) {
if value.is_empty() {
continue;
}
if let Some(locale) = convert_locale(&value) {
return locale;
}
}
}
"c".to_string()
}
pub fn catalog(locale: &str) -> Catalog {
resolve(locale).0
}
fn resolve(locale: &str) -> (Catalog, Option<&'static str>) {
let name = match locale.to_lowercase().as_str() {
"system" | "current" => system_locale(),
_ => locale.to_string(),
};
let chain = preferences(&name);
let mut merged = Catalog::default();
let mut broken = None;
for step in chain.iter().rev() {
broken = apply(&mut merged, step).or(broken);
}
merged.name = chain[0].clone();
(merged, broken)
}
thread_local! {
static MERGED: std::cell::RefCell<std::collections::HashMap<String, std::sync::Arc<Catalog>>> =
std::cell::RefCell::new(std::collections::HashMap::new());
static REPORTED: std::cell::RefCell<std::collections::HashSet<String>> =
std::cell::RefCell::new(std::collections::HashSet::new());
}
pub fn enter(locale: &str) -> Result<std::sync::Arc<Catalog>, String> {
let built = cached(locale);
let broken = resolve(locale).1;
if let Some(message) = broken {
let first = REPORTED.with(|seen| seen.borrow_mut().insert(built.name.clone()));
if first {
return Err(message.to_string());
}
}
Ok(built)
}
pub fn cached(locale: &str) -> std::sync::Arc<Catalog> {
MERGED.with(|merged| {
if let Some(hit) = merged.borrow().get(locale) {
return hit.clone();
}
let built = std::sync::Arc::new(catalog(locale));
merged
.borrow_mut()
.insert(locale.to_string(), built.clone());
built
})
}
fn apply(into: &mut Catalog, locale: &str) -> Option<&'static str> {
if let Some((_, date)) = CHANGE_DATES.iter().find(|(name, _)| *name == locale) {
into.gregorian_change_date = *date;
}
match CATALOGUES.binary_search_by(|(name, _)| (*name).cmp(locale)) {
Ok(at) => read_catalogue(into, CATALOGUES[at].1),
Err(_) => None,
}
}
fn read_catalogue(into: &mut Catalog, text: &str) -> Option<&'static str> {
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let Some(rest) = line.trim_start().strip_prefix("::msgcat::mcset ") else {
continue;
};
let mut parts = rest.trim_start().splitn(3, ' ');
let (Some(_locale), Some(key), Some(value)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
let value = value.trim();
let value = if value.starts_with("[list") {
let mut collected = value.to_string();
if !collected.trim_end().ends_with(']') {
for more in lines.by_ref() {
collected.push(' ');
collected.push_str(more.trim());
if more.trim_end().ends_with(']') {
break;
}
}
}
collected
} else {
if let Some(inner) = value.strip_prefix('"') {
if !inner.ends_with('"') || inner[..inner.len() - 1].contains('"') {
return Some("extra characters after close-quote");
}
}
value.to_string()
};
set(into, key, &value);
}
None
}
fn set(into: &mut Catalog, key: &str, raw: &str) {
match key {
"MONTHS_FULL" => into.months_full = elements(raw),
"MONTHS_ABBREV" => into.months_abbrev = elements(raw),
"DAYS_OF_WEEK_FULL" => into.days_full = elements(raw),
"DAYS_OF_WEEK_ABBREV" => into.days_abbrev = elements(raw),
"LOCALE_NUMERALS" => into.numerals = elements(raw),
"AM" => into.am = unquote(raw),
"PM" => into.pm = unquote(raw),
"BCE" => into.bce = unquote(raw),
"CE" => into.ce = unquote(raw),
"DATE_FORMAT" => into.date_format = unquote(raw),
"TIME_FORMAT" => into.time_format = unquote(raw),
"TIME_FORMAT_12" => into.time_format_12 = unquote(raw),
"TIME_FORMAT_24" => into.time_format_24 = unquote(raw),
"TIME_FORMAT_24_SECS" => into.time_format_24_secs = unquote(raw),
"DATE_TIME_FORMAT" => into.date_time_format = unquote(raw),
"LOCALE_DATE_FORMAT" => into.locale_date_format = unquote(raw),
"LOCALE_TIME_FORMAT" => into.locale_time_format = unquote(raw),
"LOCALE_DATE_TIME_FORMAT" => into.locale_date_time_format = unquote(raw),
"LOCALE_YEAR_FORMAT" => into.locale_year_format = unquote(raw),
"LOCALE_ERAS" => into.eras = eras(&unquote(raw)),
"GREGORIAN_CHANGE_DATE" => {
if let Some(day) = crate::list::parse_int(raw.trim()) {
into.gregorian_change_date = day;
}
}
_ => {}
}
}
fn unquote(raw: &str) -> String {
let trimmed = raw.trim();
trimmed
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(trimmed)
.to_string()
}
fn elements(raw: &str) -> Vec<String> {
if raw.trim_start().starts_with("[list") {
return quoted_words(raw);
}
crate::list::split(&unquote(raw)).unwrap_or_default()
}
fn quoted_words(raw: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = raw;
while let Some(at) = rest.find('"') {
rest = &rest[at + 1..];
let Some(end) = rest.find('"') else { break };
out.push(rest[..end].to_string());
rest = &rest[end + 1..];
}
out
}
fn eras(raw: &str) -> Vec<Era> {
let Ok(rows) = crate::list::split(raw) else {
return Vec::new();
};
let mut out: Vec<Era> = rows
.iter()
.filter_map(|row| {
let fields = crate::list::split(row).ok()?;
let [start, name, year] = fields.as_slice() else {
return None;
};
Some(Era {
start: crate::list::parse_int(start.trim())?,
name: name.clone(),
year: crate::list::parse_int(year.trim())?,
})
})
.collect();
out.sort_by_key(|era| era.start);
out
}
impl Catalog {
pub fn era_at(&self, local_seconds: i64) -> Option<&Era> {
let at = self.eras.partition_point(|era| era.start <= local_seconds);
(at > 0).then(|| &self.eras[at - 1])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_chain_runs_from_the_name_to_the_root() {
assert_eq!(preferences("de_DE"), ["de_de", "de", ""]);
assert_eq!(
preferences("en_US_roman"),
["en_us_roman", "en_us", "en", ""]
);
assert_eq!(preferences(""), [""]);
}
#[test]
fn a_locale_inherits_what_it_does_not_set() {
let bolivia = catalog("es_BO");
assert_eq!(bolivia.date_format, "%d-%m-%Y");
assert_eq!(bolivia.months_abbrev[0], "ene");
assert_eq!(bolivia.bce, "a.C.");
assert_eq!(bolivia.months_abbrev, catalog("es").months_abbrev);
assert_eq!(bolivia.bce, catalog("es").bce);
assert_eq!(catalog("es").date_format, "%e de %B de %Y");
}
#[test]
fn an_unknown_locale_is_the_root_locale() {
let unknown = catalog("zz_ZZ");
assert_eq!(unknown.months_full, Catalog::default().months_full);
assert_eq!(unknown.bce, "B.C.E.");
assert_eq!(unknown.date_format, "%m/%d/%Y");
assert_eq!(unknown.name, "zz_zz");
assert_eq!(catalog("es_BO").name, "es_bo");
assert_eq!(catalog("").name, "");
}
#[test]
fn the_change_date_comes_from_the_language_when_the_country_sets_none() {
assert_eq!(catalog("de_DE").gregorian_change_date, 2342032);
assert_eq!(catalog("de_AT").gregorian_change_date, 2299527);
assert_eq!(catalog("de").gregorian_change_date, 2299161);
assert_eq!(catalog("en_GB").gregorian_change_date, 2361222);
}
#[test]
fn eras_are_read_and_searched() {
let japan = catalog("ja");
assert_eq!(japan.eras.len(), 6);
let era = japan.era_at(1234567890).expect("an era covers 2009");
assert_eq!(era.name, "平成");
assert_eq!(era.year, 1988);
}
#[test]
fn a_list_written_as_one_quoted_word_is_still_a_list() {
let chinese = catalog("zh");
assert_eq!(chinese.numerals.len(), 100);
assert_eq!(chinese.numerals[0], "〇");
assert_eq!(chinese.numerals[21], "廿一");
assert_eq!(catalog("de").months_abbrev[2], "Mrz");
}
#[test]
fn a_codeset_and_a_modifier_are_dropped() {
assert_eq!(convert_locale("de_DE.UTF-8"), Some("de_DE".to_string()));
assert_eq!(
convert_locale("sr_RS@latin"),
Some("sr_RS_latin".to_string())
);
assert_eq!(convert_locale(".UTF-8"), None);
}
}