use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::RwLock;
pub const BASE: &str = "en";
const EN_TOML: &str = include_str!("../locales/en.toml");
const NL_TOML: &str = include_str!("../locales/nl.toml");
const BG_TOML: &str = include_str!("../locales/bg.toml");
const CS_TOML: &str = include_str!("../locales/cs.toml");
const DA_TOML: &str = include_str!("../locales/da.toml");
const DE_TOML: &str = include_str!("../locales/de.toml");
const EL_TOML: &str = include_str!("../locales/el.toml");
const ES_TOML: &str = include_str!("../locales/es.toml");
const FI_TOML: &str = include_str!("../locales/fi.toml");
const FR_TOML: &str = include_str!("../locales/fr.toml");
const HR_TOML: &str = include_str!("../locales/hr.toml");
const HU_TOML: &str = include_str!("../locales/hu.toml");
const ID_TOML: &str = include_str!("../locales/id.toml");
const IT_TOML: &str = include_str!("../locales/it.toml");
const NO_TOML: &str = include_str!("../locales/no.toml");
const PL_TOML: &str = include_str!("../locales/pl.toml");
const PT_TOML: &str = include_str!("../locales/pt.toml");
const RO_TOML: &str = include_str!("../locales/ro.toml");
const RU_TOML: &str = include_str!("../locales/ru.toml");
const SK_TOML: &str = include_str!("../locales/sk.toml");
const SR_TOML: &str = include_str!("../locales/sr.toml");
const SV_TOML: &str = include_str!("../locales/sv.toml");
const TR_TOML: &str = include_str!("../locales/tr.toml");
const UK_TOML: &str = include_str!("../locales/uk.toml");
const VI_TOML: &str = include_str!("../locales/vi.toml");
const LOCALES: &[(&str, &str)] = &[
("en", EN_TOML),
("bg", BG_TOML),
("cs", CS_TOML),
("da", DA_TOML),
("de", DE_TOML),
("el", EL_TOML),
("es", ES_TOML),
("fi", FI_TOML),
("fr", FR_TOML),
("hr", HR_TOML),
("hu", HU_TOML),
("id", ID_TOML),
("it", IT_TOML),
("nl", NL_TOML),
("no", NO_TOML),
("pl", PL_TOML),
("pt", PT_TOML),
("ro", RO_TOML),
("ru", RU_TOML),
("sk", SK_TOML),
("sr", SR_TOML),
("sv", SV_TOML),
("tr", TR_TOML),
("uk", UK_TOML),
("vi", VI_TOML),
];
type FlatTable = HashMap<String, String>;
static TABLES: Lazy<HashMap<&'static str, FlatTable>> = Lazy::new(|| {
let mut out: HashMap<&'static str, FlatTable> = HashMap::new();
for (code, src) in LOCALES {
let parsed: toml::Value =
toml::from_str(src).unwrap_or_else(|e| panic!("locale '{code}' has invalid TOML: {e}"));
let mut flat = FlatTable::new();
flatten("", &parsed, &mut flat);
out.insert(*code, flat);
}
out
});
static CURRENT: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(BASE.into()));
fn flatten(prefix: &str, v: &toml::Value, out: &mut FlatTable) {
match v {
toml::Value::String(s) => {
out.insert(prefix.to_string(), s.clone());
}
toml::Value::Table(t) => {
for (k, v) in t {
let next = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
flatten(&next, v, out);
}
}
_ => { }
}
}
pub fn set_locale(code: &str) {
let normalised = normalise(code);
let chosen = if TABLES.contains_key(normalised.as_str()) {
normalised
} else {
BASE.to_string()
};
if let Ok(mut w) = CURRENT.write() {
*w = chosen;
}
}
pub fn current_locale() -> String {
CURRENT
.read()
.ok()
.map(|g| g.clone())
.unwrap_or_else(|| BASE.to_string())
}
pub fn available_locales() -> Vec<&'static str> {
let mut v: Vec<&'static str> = TABLES.keys().copied().collect();
v.sort();
v
}
pub fn is_supported(code: &str) -> bool {
TABLES.contains_key(normalise(code).as_str())
}
pub fn native_name(code: &str) -> String {
let n = normalise(code);
TABLES
.get(n.as_str())
.and_then(|m| m.get("locale.native_name"))
.cloned()
.unwrap_or_else(|| code.to_string())
}
pub fn t(key: &str) -> String {
let locale = current_locale();
if let Some(table) = TABLES.get(locale.as_str()) {
if let Some(s) = table.get(key) {
return s.clone();
}
}
if locale != BASE {
if let Some(table) = TABLES.get(BASE) {
if let Some(s) = table.get(key) {
return s.clone();
}
}
}
key.to_string()
}
pub fn t_with(key: &str, args: &[(&str, &str)]) -> String {
let mut s = t(key);
for (name, value) in args {
s = s.replace(&format!("{{{name}}}"), value);
}
s
}
fn normalise(code: &str) -> String {
code.split(['_', '-', '.'])
.next()
.unwrap_or(code)
.to_lowercase()
}
#[cfg(test)]
pub(crate) mod testing {
use super::{set_locale, BASE};
use std::sync::{Mutex, MutexGuard, OnceLock};
fn lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
pub struct LocaleTestGuard {
_lock: MutexGuard<'static, ()>,
}
impl LocaleTestGuard {
pub fn set(code: &str) -> Self {
let lock = lock().lock().unwrap_or_else(|p| p.into_inner());
set_locale(code);
Self { _lock: lock }
}
}
impl Drop for LocaleTestGuard {
fn drop(&mut self) {
set_locale(BASE);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::testing::LocaleTestGuard;
#[test]
fn base_locale_is_always_supported() {
assert!(is_supported(BASE));
assert!(available_locales().contains(&BASE));
}
#[test]
fn normalise_strips_region_and_codeset() {
assert_eq!(normalise("nl_NL.UTF-8"), "nl");
assert_eq!(normalise("NL-nl"), "nl");
assert_eq!(normalise("EN"), "en");
assert_eq!(normalise("zh-CN"), "zh");
}
#[test]
fn unsupported_locale_falls_back_to_base() {
let _g = LocaleTestGuard::set("klingon");
assert_eq!(current_locale(), BASE);
}
#[test]
fn t_falls_back_to_base_when_key_missing_in_active_locale() {
let _g = LocaleTestGuard::set("nl");
let s = t("app.name");
assert!(
!s.is_empty() && s != "app.name",
"expected English fallback for missing nl key, got: {s:?}"
);
}
#[test]
fn t_returns_literal_key_for_unknown_anywhere() {
let _g = LocaleTestGuard::set(BASE);
let s = t("this.key.does.not.exist.anywhere");
assert_eq!(s, "this.key.does.not.exist.anywhere");
}
#[test]
fn t_with_substitutes_placeholders() {
let _g = LocaleTestGuard::set("en");
let s = t_with("test.fixture.greeting", &[("name", "world")]);
assert_eq!(s, "Hello, world!");
}
}