use std::collections::HashMap;
use std::path::Path;
use std::sync::RwLock;
pub mod admin;
pub mod db;
pub mod middleware;
pub mod tera_tags;
pub mod timezone;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Locale(String);
impl Locale {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into().to_lowercase())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn base_language(&self) -> &str {
self.0.split('-').next().unwrap_or(&self.0)
}
#[must_use]
pub fn is_rtl(&self) -> bool {
is_rtl_base(self.base_language())
}
#[must_use]
pub fn direction(&self) -> &'static str {
if self.is_rtl() {
"rtl"
} else {
"ltr"
}
}
}
#[must_use]
pub fn is_rtl_language(locale: &str) -> bool {
let lower = locale.to_ascii_lowercase();
let base = lower.split('-').next().unwrap_or(&lower);
is_rtl_base(base)
}
#[must_use]
pub fn text_direction(locale: &str) -> &'static str {
if is_rtl_language(locale) {
"rtl"
} else {
"ltr"
}
}
impl Locale {
#[must_use]
pub fn display_name(&self) -> &'static str {
language_display_name(self.base_language())
}
#[must_use]
pub fn native_name(&self) -> &'static str {
language_native_name(self.base_language())
}
}
const LANGUAGE_NAMES: &[(&str, &str, &str)] = &[
("en", "English", "English"),
("fr", "French", "français"),
("de", "German", "Deutsch"),
("es", "Spanish", "español"),
("it", "Italian", "italiano"),
("pt", "Portuguese", "português"),
("nl", "Dutch", "Nederlands"),
("ru", "Russian", "русский"),
("ja", "Japanese", "日本語"),
("zh", "Chinese", "中文"),
("ko", "Korean", "한국어"),
("ar", "Arabic", "العربية"),
("he", "Hebrew", "עברית"),
("fa", "Persian", "فارسی"),
("ur", "Urdu", "اردو"),
("tr", "Turkish", "Türkçe"),
("pl", "Polish", "polski"),
("uk", "Ukrainian", "українська"),
("cs", "Czech", "čeština"),
("sk", "Slovak", "slovenčina"),
("hu", "Hungarian", "magyar"),
("ro", "Romanian", "română"),
("bg", "Bulgarian", "български"),
("el", "Greek", "Ελληνικά"),
("sv", "Swedish", "svenska"),
("no", "Norwegian", "norsk"),
("da", "Danish", "dansk"),
("fi", "Finnish", "suomi"),
("hi", "Hindi", "हिन्दी"),
("bn", "Bengali", "বাংলা"),
("ta", "Tamil", "தமிழ்"),
("th", "Thai", "ไทย"),
("vi", "Vietnamese", "Tiếng Việt"),
("id", "Indonesian", "Bahasa Indonesia"),
("ms", "Malay", "Bahasa Melayu"),
("ps", "Pashto", "پښتو"),
("yi", "Yiddish", "ייִדיש"),
("dv", "Divehi", "ދިވެހި"),
("ckb", "Sorani Kurdish", "کوردیی ناوەندی"),
("ug", "Uyghur", "ئۇيغۇرچە"),
("sd", "Sindhi", "سنڌي"),
("syr", "Syriac", "ܠܫܢܐ ܣܘܪܝܝܐ"),
];
fn canonical_base(base: &str) -> &str {
match base {
"iw" => "he", "nb" | "nn" => "no", "ji" => "yi", other => other,
}
}
fn language_row(locale: &str) -> Option<&'static (&'static str, &'static str, &'static str)> {
let lower = locale.to_ascii_lowercase();
let base = canonical_base(lower.split('-').next().unwrap_or(&lower));
LANGUAGE_NAMES.iter().find(|(code, _, _)| *code == base)
}
#[must_use]
pub fn language_display_name(locale: &str) -> &'static str {
language_row(locale).map_or("Unknown", |(_, en, _)| *en)
}
#[must_use]
pub fn language_native_name(locale: &str) -> &'static str {
language_row(locale).map_or("Unknown", |(_, _, native)| *native)
}
pub fn known_locales() -> impl Iterator<Item = (&'static str, &'static str)> {
LANGUAGE_NAMES.iter().map(|(code, en, _)| (*code, *en))
}
#[derive(Debug, Clone)]
pub struct LocaleInfo {
pub code: String,
pub known: bool,
pub display_name: &'static str,
pub native_name: &'static str,
pub direction: &'static str,
pub is_rtl: bool,
pub has_plural_rules: bool,
}
#[must_use]
pub fn locale_info(code: &str) -> LocaleInfo {
let display_name = language_display_name(code);
LocaleInfo {
code: code.to_ascii_lowercase(),
known: display_name != "Unknown",
display_name,
native_name: language_native_name(code),
direction: text_direction(code),
is_rtl: is_rtl_language(code),
has_plural_rules: plural_category_is_explicit(code),
}
}
fn is_rtl_base(base: &str) -> bool {
matches!(
base,
"ar" | "he" | "iw" | "fa" | "ur" | "ps" | "yi" | "ji" | "dv" | "ckb" | "ug" | "sd" | "syr" )
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, thiserror::Error)]
pub enum I18nError {
#[error("io error: {0}")]
Io(String),
#[error("parse error in {file}: {detail}")]
Parse { file: String, detail: String },
}
pub struct Translator {
default_locale: Locale,
fallback_chain: Vec<Locale>,
catalogs: RwLock<HashMap<Locale, HashMap<String, String>>>,
overrides: RwLock<HashMap<Locale, HashMap<String, String>>>,
plural_catalogs: RwLock<HashMap<Locale, HashMap<String, HashMap<String, String>>>>,
}
impl Translator {
#[must_use]
pub fn new(default_locale: Locale) -> Self {
Self {
default_locale,
fallback_chain: Vec::new(),
catalogs: RwLock::new(HashMap::new()),
overrides: RwLock::new(HashMap::new()),
plural_catalogs: RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn with_fallback_chain(mut self, chain: &[&str]) -> Self {
let mut seen: std::collections::HashSet<Locale> = std::collections::HashSet::new();
seen.insert(self.default_locale.clone());
self.fallback_chain = chain
.iter()
.map(|s| Locale::new(*s))
.filter(|loc| seen.insert(loc.clone()))
.collect();
self
}
#[must_use]
pub fn fallback_chain(&self) -> &[Locale] {
&self.fallback_chain
}
#[must_use]
pub fn add_locale(self, locale: Locale, catalog: HashMap<String, String>) -> Self {
self.catalogs
.write()
.expect("translator poisoned")
.insert(locale, catalog);
self
}
pub fn insert_locale(&self, locale: Locale, catalog: HashMap<String, String>) {
self.catalogs
.write()
.expect("translator poisoned")
.insert(locale, catalog);
}
#[must_use]
pub fn add_plural_locale(
self,
locale: Locale,
catalog: HashMap<String, HashMap<String, String>>,
) -> Self {
self.plural_catalogs
.write()
.expect("translator poisoned")
.insert(locale, catalog);
self
}
pub fn insert_plural_locale(
&self,
locale: Locale,
catalog: HashMap<String, HashMap<String, String>>,
) {
self.plural_catalogs
.write()
.expect("translator poisoned")
.insert(locale, catalog);
}
#[must_use]
pub fn translate(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
let req = Locale::new(locale);
{
let ov = self.overrides.read().expect("translator poisoned");
if let Some(v) = ov.get(&req).and_then(|c| c.get(key)).or_else(|| {
ov.get(&Locale::new(req.base_language()))
.and_then(|c| c.get(key))
}) {
return substitute(v, params);
}
}
let cats = self.catalogs.read().expect("translator poisoned");
let mut template: Option<String> = cats
.get(&req)
.and_then(|c| c.get(key))
.or_else(|| {
cats.get(&Locale::new(req.base_language()))
.and_then(|c| c.get(key))
})
.cloned();
if template.is_none() {
for fb in &self.fallback_chain {
if let Some(v) = cats.get(fb).and_then(|c| c.get(key)) {
template = Some(v.clone());
break;
}
}
}
let template = template
.or_else(|| {
cats.get(&self.default_locale)
.and_then(|c| c.get(key))
.cloned()
})
.unwrap_or_else(|| key.to_owned());
substitute(&template, params)
}
fn resolve_plural_template(&self, req: &Locale, key: &str, category: &str) -> Option<String> {
let pc = self.plural_catalogs.read().expect("translator poisoned");
let pick = |entry: &HashMap<String, String>| -> Option<String> {
entry
.get(category)
.or_else(|| entry.get("other"))
.or_else(|| entry.values().next())
.cloned()
};
if let Some(v) = pc.get(req).and_then(|c| c.get(key)).and_then(&pick) {
return Some(v);
}
let base = Locale::new(req.base_language());
if let Some(v) = pc.get(&base).and_then(|c| c.get(key)).and_then(&pick) {
return Some(v);
}
for fb in &self.fallback_chain {
if let Some(v) = pc.get(fb).and_then(|c| c.get(key)).and_then(&pick) {
return Some(v);
}
}
pc.get(&self.default_locale)
.and_then(|c| c.get(key))
.and_then(&pick)
}
#[must_use]
pub fn translate_plural(
&self,
locale: &str,
key: &str,
n: i64,
params: &[(&str, &str)],
) -> String {
let req = Locale::new(locale);
let category = plural_category(locale, n);
if let Some(tpl) = self.resolve_plural_template(&req, key, category) {
return substitute(&tpl, params);
}
self.translate(locale, key, params)
}
#[must_use]
pub fn has_locale(&self, locale: &str) -> bool {
let req = Locale::new(locale);
let base = Locale::new(req.base_language());
let cats = self.catalogs.read().expect("translator poisoned");
if cats.contains_key(&req) || cats.contains_key(&base) {
return true;
}
let ov = self.overrides.read().expect("translator poisoned");
ov.contains_key(&req) || ov.contains_key(&base)
}
#[must_use]
pub fn locales(&self) -> Vec<String> {
self.catalogs
.read()
.expect("translator poisoned")
.keys()
.map(|l| l.0.clone())
.collect()
}
#[must_use]
pub fn entries(&self) -> Vec<(String, String, String)> {
let cats = self.catalogs.read().expect("translator poisoned");
let mut out = Vec::new();
for (loc, catalog) in cats.iter() {
for (k, v) in catalog {
out.push((loc.0.clone(), k.clone(), v.clone()));
}
}
out
}
pub fn load_overrides(&self, rows: impl IntoIterator<Item = (String, String, String)>) {
let mut map: HashMap<Locale, HashMap<String, String>> = HashMap::new();
for (locale, key, value) in rows {
map.entry(Locale::new(&locale))
.or_default()
.insert(key, value);
}
*self.overrides.write().expect("translator poisoned") = map;
}
pub fn set_override(&self, locale: &str, key: impl Into<String>, value: impl Into<String>) {
self.overrides
.write()
.expect("translator poisoned")
.entry(Locale::new(locale))
.or_default()
.insert(key.into(), value.into());
}
pub fn clear_overrides(&self) {
self.overrides.write().expect("translator poisoned").clear();
}
#[must_use]
pub fn override_count(&self) -> usize {
self.overrides
.read()
.expect("translator poisoned")
.values()
.map(HashMap::len)
.sum()
}
pub fn from_directory(dir: &Path, default_locale: Locale) -> Result<Self, I18nError> {
let t = Translator::new(default_locale);
let entries = std::fs::read_dir(dir).map_err(|e| I18nError::Io(e.to_string()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let raw = std::fs::read_to_string(&path).map_err(|e| I18nError::Io(e.to_string()))?;
let catalog: HashMap<String, String> =
serde_json::from_str(&raw).map_err(|e| I18nError::Parse {
file: path.display().to_string(),
detail: e.to_string(),
})?;
t.insert_locale(Locale::new(stem), catalog);
}
Ok(t)
}
#[cfg(feature = "config")]
pub fn from_settings(settings: &crate::config::I18nSettings) -> Result<Self, I18nError> {
let default = settings.default_locale.as_deref().unwrap_or("en");
let mut t = Translator::new(Locale::new(default));
let allowlist: Option<std::collections::HashSet<String>> = if settings.languages.is_empty()
{
None
} else {
Some(settings.languages.iter().cloned().collect())
};
for raw_path in &settings.locale_paths {
let path = std::path::Path::new(raw_path);
let entries = std::fs::read_dir(path)
.map_err(|e| I18nError::Io(format!("{}: {e}", path.display())))?;
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Some(allow) = &allowlist {
if !allow.contains(stem) {
continue;
}
}
let raw = std::fs::read_to_string(&p).map_err(|e| I18nError::Io(e.to_string()))?;
let catalog: HashMap<String, String> =
serde_json::from_str(&raw).map_err(|e| I18nError::Parse {
file: p.display().to_string(),
detail: e.to_string(),
})?;
t.insert_locale(Locale::new(stem), catalog);
}
}
if !settings.fallback_chain.is_empty() {
let chain: Vec<&str> = settings.fallback_chain.iter().map(String::as_str).collect();
t = t.with_fallback_chain(&chain);
}
Ok(t)
}
#[must_use]
pub fn gettext(&self, locale: &str, key: &str) -> String {
self.translate(locale, key, &[])
}
#[must_use]
pub fn gettext_fmt(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
self.translate(locale, key, params)
}
#[must_use]
pub fn pgettext(&self, locale: &str, context: &str, key: &str) -> String {
let composite = format!("{context}\u{0004}{key}");
let translated = self.translate(locale, &composite, &[]);
if translated == composite {
return self.translate(locale, key, &[]);
}
translated
}
#[must_use]
pub fn pgettext_fmt(
&self,
locale: &str,
context: &str,
key: &str,
params: &[(&str, &str)],
) -> String {
let raw = self.pgettext(locale, context, key);
substitute(&raw, params)
}
#[must_use]
pub fn ngettext(&self, locale: &str, singular: &str, plural: &str, count: i64) -> String {
let key = if plural_category(locale, count) == "one" {
singular
} else {
plural
};
let count_str = count.to_string();
self.translate(locale, key, &[("count", &count_str)])
}
#[must_use]
pub fn ngettext_fmt(
&self,
locale: &str,
singular: &str,
plural: &str,
count: i64,
params: &[(&str, &str)],
) -> String {
let key = if plural_category(locale, count) == "one" {
singular
} else {
plural
};
let count_str = count.to_string();
let mut all: Vec<(&str, &str)> = Vec::with_capacity(params.len() + 1);
all.push(("count", &count_str));
all.extend_from_slice(params);
self.translate(locale, key, &all)
}
}
fn substitute(template: &str, params: &[(&str, &str)]) -> String {
let mut out = template.to_owned();
for (name, value) in params {
let placeholder = format!("{{{name}}}");
out = out.replace(&placeholder, value);
}
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum PluralFamily {
NoDistinction,
FrenchStyle,
WestSlavic,
EastSlavic,
}
fn plural_family(base: &str) -> Option<PluralFamily> {
match base {
"zh" | "ja" | "ko" | "th" | "vi" | "id" | "ms" | "lo" | "km" | "my" => {
Some(PluralFamily::NoDistinction)
}
"fr" | "pt" | "ff" | "hy" | "kab" => Some(PluralFamily::FrenchStyle),
"pl" => Some(PluralFamily::WestSlavic),
"uk" | "ru" | "be" => Some(PluralFamily::EastSlavic),
_ => None,
}
}
#[must_use]
pub fn plural_category(locale: &str, n: i64) -> &'static str {
let base = Locale::new(locale);
let n = n.unsigned_abs();
let r10 = n % 10;
let r100 = n % 100;
match plural_family(base.base_language()) {
Some(PluralFamily::NoDistinction) => "other",
Some(PluralFamily::FrenchStyle) => {
if n == 0 || n == 1 {
"one"
} else {
"other"
}
}
Some(PluralFamily::WestSlavic) => {
if n == 1 {
"one"
} else if (2..=4).contains(&r10) && !(12..=14).contains(&r100) {
"few"
} else {
"many"
}
}
Some(PluralFamily::EastSlavic) => {
if r10 == 1 && r100 != 11 {
"one"
} else if (2..=4).contains(&r10) && !(12..=14).contains(&r100) {
"few"
} else {
"many"
}
}
None => {
if n == 1 {
"one"
} else {
"other"
}
}
}
}
#[must_use]
pub fn plural_category_is_explicit(locale: &str) -> bool {
plural_family(Locale::new(locale).base_language()).is_some()
}
#[must_use]
pub fn negotiate_language<S: AsRef<str>>(accept_language: &str, available: &[S]) -> Option<String> {
let mut prefs = parse_accept_language(accept_language);
prefs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let avail_lower: Vec<String> = available
.iter()
.map(|s| s.as_ref().to_lowercase())
.collect();
for (lang, _q) in prefs {
let lang_lower = lang.to_lowercase();
if let Some(matched) = avail_lower.iter().find(|a| **a == lang_lower) {
return Some(matched.clone());
}
let base = lang_lower.split('-').next().unwrap_or(&lang_lower);
if let Some(matched) = avail_lower
.iter()
.find(|a| **a == base || a.starts_with(&format!("{base}-")))
{
return Some(matched.clone());
}
}
None
}
fn parse_accept_language(header: &str) -> Vec<(String, f32)> {
header
.split(',')
.filter_map(|raw| {
let mut parts = raw.split(';').map(str::trim);
let lang = parts.next()?.to_owned();
if lang.is_empty() {
return None;
}
let mut q = 1.0;
for kv in parts {
if let Some(rest) = kv.strip_prefix("q=") {
if let Ok(parsed) = rest.parse::<f32>() {
q = parsed;
}
}
}
Some((lang, q))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_translator() -> Translator {
let mut en = HashMap::new();
en.insert("hello".into(), "Hello".into());
en.insert("greet".into(), "Hi, {name}!".into());
let mut fr = HashMap::new();
fr.insert("hello".into(), "Bonjour".into());
fr.insert("greet".into(), "Salut, {name} !".into());
Translator::new(Locale::new("en"))
.add_locale(Locale::new("en"), en)
.add_locale(Locale::new("fr"), fr)
}
#[test]
fn translate_basic() {
let t = make_translator();
assert_eq!(t.translate("en", "hello", &[]), "Hello");
assert_eq!(t.translate("fr", "hello", &[]), "Bonjour");
}
#[test]
fn translate_substitutes_params() {
let t = make_translator();
assert_eq!(
t.translate("en", "greet", &[("name", "Alice")]),
"Hi, Alice!"
);
assert_eq!(
t.translate("fr", "greet", &[("name", "Alice")]),
"Salut, Alice !"
);
}
#[test]
fn unknown_locale_falls_back_to_default() {
let t = make_translator();
assert_eq!(t.translate("ja", "hello", &[]), "Hello");
}
#[test]
fn unknown_key_returns_key_itself() {
let t = make_translator();
assert_eq!(t.translate("en", "unknown.key", &[]), "unknown.key");
}
#[test]
fn region_falls_back_to_base_language() {
let t = make_translator();
assert_eq!(t.translate("fr-FR", "hello", &[]), "Bonjour");
}
#[test]
fn has_locale_with_base_match() {
let t = make_translator();
assert!(t.has_locale("en"));
assert!(t.has_locale("fr"));
assert!(t.has_locale("en-US"));
assert!(!t.has_locale("ja"));
}
#[test]
fn fallback_chain_walks_in_order() {
let mut es: HashMap<String, String> = HashMap::new();
es.insert("welcome".into(), "Bienvenido".into());
let mut pt: HashMap<String, String> = HashMap::new();
pt.insert("hello".into(), "Olá".into());
let mut en: HashMap<String, String> = HashMap::new();
en.insert("hello".into(), "Hello".into());
let t = Translator::new(Locale::new("en"))
.with_fallback_chain(&["pt"])
.add_locale(Locale::new("es"), es)
.add_locale(Locale::new("pt"), pt)
.add_locale(Locale::new("en"), en);
assert_eq!(t.translate("es", "welcome", &[]), "Bienvenido");
assert_eq!(t.translate("es", "hello", &[]), "Olá");
}
#[test]
fn fallback_chain_then_default() {
let mut en: HashMap<String, String> = HashMap::new();
en.insert("hello".into(), "Hello".into());
let pt: HashMap<String, String> = HashMap::new();
let t = Translator::new(Locale::new("en"))
.with_fallback_chain(&["pt"])
.add_locale(Locale::new("pt"), pt)
.add_locale(Locale::new("en"), en);
assert_eq!(t.translate("es", "hello", &[]), "Hello");
}
#[test]
fn fallback_chain_accessor_deduplicates() {
let t = Translator::new(Locale::new("en")).with_fallback_chain(&["pt", "pt", "en", "es"]);
let chain: Vec<&str> = t.fallback_chain().iter().map(Locale::as_str).collect();
assert_eq!(chain, vec!["pt", "es"]);
}
#[test]
fn locales_lists_registered() {
let t = make_translator();
let mut locales = t.locales();
locales.sort();
assert_eq!(locales, vec!["en".to_string(), "fr".to_string()]);
}
#[test]
fn locale_is_rtl_for_arabic_hebrew_persian_urdu() {
for code in ["ar", "he", "fa", "ur", "ps", "yi", "dv", "ckb", "ug", "sd"] {
let loc = Locale::new(code);
assert!(loc.is_rtl(), "{code} should be RTL");
assert_eq!(loc.direction(), "rtl", "{code} direction should be rtl");
}
}
#[test]
fn locale_is_ltr_for_western_and_cjk() {
for code in ["en", "fr", "de", "es", "ja", "zh", "ko", "ru", "tr", "pt"] {
let loc = Locale::new(code);
assert!(!loc.is_rtl(), "{code} should be LTR");
assert_eq!(loc.direction(), "ltr", "{code} direction should be ltr");
}
}
#[test]
fn rtl_check_uses_base_language_for_region_subtag() {
assert!(Locale::new("ar-EG").is_rtl());
assert!(Locale::new("AR-SA").is_rtl()); assert!(Locale::new("he-IL").is_rtl());
assert!(!Locale::new("en-US").is_rtl());
assert!(!Locale::new("pt-BR").is_rtl());
}
#[test]
fn rtl_check_handles_retired_iso_codes() {
assert!(Locale::new("iw").is_rtl()); assert!(Locale::new("ji").is_rtl()); }
#[test]
fn display_name_returns_english_label_for_known_locales() {
assert_eq!(Locale::new("en").display_name(), "English");
assert_eq!(Locale::new("fr-FR").display_name(), "French");
assert_eq!(Locale::new("zh-CN").display_name(), "Chinese");
assert_eq!(Locale::new("ar-EG").display_name(), "Arabic");
assert_eq!(Locale::new("he-IL").display_name(), "Hebrew");
assert_eq!(Locale::new("iw").display_name(), "Hebrew"); }
#[test]
fn native_name_returns_endonym_for_known_locales() {
assert_eq!(Locale::new("en").native_name(), "English");
assert_eq!(Locale::new("fr-CA").native_name(), "français");
assert_eq!(Locale::new("ja").native_name(), "日本語");
assert_eq!(Locale::new("zh-TW").native_name(), "中文");
assert_eq!(Locale::new("ar").native_name(), "العربية");
assert_eq!(Locale::new("he").native_name(), "עברית");
}
#[test]
fn unknown_locale_falls_back_to_unknown_label() {
assert_eq!(Locale::new("xx").display_name(), "Unknown");
assert_eq!(Locale::new("xx").native_name(), "Unknown");
}
#[test]
fn norwegian_variants_share_one_label() {
for code in ["no", "nb", "nn", "nb-NO", "nn-NO"] {
assert_eq!(Locale::new(code).display_name(), "Norwegian", "{code}");
assert_eq!(Locale::new(code).native_name(), "norsk", "{code}");
}
}
#[test]
fn locale_info_summarizes_core_support() {
let fr = locale_info("fr-CA");
assert!(fr.known);
assert_eq!(fr.display_name, "French");
assert!(!fr.is_rtl && fr.direction == "ltr");
assert!(fr.has_plural_rules);
let ar = locale_info("ar");
assert!(ar.known && ar.is_rtl && ar.direction == "rtl");
let en = locale_info("EN");
assert_eq!(en.code, "en"); assert!(en.known && !en.has_plural_rules);
let xx = locale_info("xx");
assert!(!xx.known && xx.display_name == "Unknown" && !xx.is_rtl && !xx.has_plural_rules);
}
#[test]
fn known_locales_lists_canonical_codes_only() {
let codes: Vec<&str> = known_locales().map(|(c, _)| c).collect();
assert!(codes.contains(&"en") && codes.contains(&"zh") && codes.contains(&"ar"));
assert!(!codes.contains(&"iw") && !codes.contains(&"nb") && !codes.contains(&"ji"));
assert_eq!(language_display_name("iw"), "Hebrew");
}
#[test]
fn plural_explicit_flag_matches_families() {
for c in ["pl", "uk", "ru", "zh-Hans", "fr", "pt-BR", "ja"] {
assert!(
plural_category_is_explicit(c),
"{c} has a language-specific rule"
);
}
for c in ["en", "de", "es", "xx"] {
assert!(!plural_category_is_explicit(c), "{c} uses the generic rule");
}
}
#[test]
fn bare_string_helpers_match_locale_methods() {
for code in ["ar", "fa-IR", "he-IL", "iw"] {
assert!(is_rtl_language(code), "{code} should be RTL");
assert_eq!(text_direction(code), "rtl");
}
for code in ["en", "fr-FR", "ja", "zh-CN"] {
assert!(!is_rtl_language(code), "{code} should be LTR");
assert_eq!(text_direction(code), "ltr");
}
}
#[test]
fn negotiate_picks_highest_q() {
let lang = negotiate_language("en;q=0.5,fr;q=0.9,de;q=0.1", &["en", "fr", "de"]);
assert_eq!(lang.as_deref(), Some("fr"));
}
#[test]
fn negotiate_falls_back_to_base() {
let lang = negotiate_language("fr-FR,fr;q=0.9,en;q=0.8", &["en", "fr"]);
assert_eq!(lang.as_deref(), Some("fr"));
}
#[test]
fn negotiate_no_match_returns_none() {
let lang = negotiate_language("ja,zh", &["en", "fr"]);
assert_eq!(lang, None);
}
#[test]
fn negotiate_uses_default_q_of_1() {
let lang = negotiate_language("en,fr;q=0.5", &["en", "fr"]);
assert_eq!(lang.as_deref(), Some("en"));
}
#[test]
fn negotiate_empty_accept_language_returns_none() {
let lang = negotiate_language("", &["en", "fr"]);
assert_eq!(lang, None);
}
fn translator_with_en_fr() -> Translator {
let mut en = HashMap::new();
en.insert("welcome".into(), "Welcome, {name}!".into());
en.insert(
"count_unread".into(),
"You have {count} unread message.".into(),
);
en.insert(
"count_unread_plural".into(),
"You have {count} unread messages.".into(),
);
en.insert("verb\u{0004}save".into(), "Save".into()); en.insert("noun\u{0004}save".into(), "Discount".into()); en.insert("save".into(), "Save (bare)".into());
let mut fr = HashMap::new();
fr.insert("welcome".into(), "Bienvenue, {name} !".into());
fr.insert(
"count_unread".into(),
"Vous avez {count} message non lu.".into(),
);
fr.insert(
"count_unread_plural".into(),
"Vous avez {count} messages non lus.".into(),
);
Translator::new(Locale::new("en"))
.add_locale(Locale::new("en"), en)
.add_locale(Locale::new("fr"), fr)
}
#[test]
fn gettext_returns_translated_string_no_params() {
let t = translator_with_en_fr();
assert_eq!(t.gettext("en", "welcome"), "Welcome, {name}!");
}
#[test]
fn gettext_fmt_substitutes_placeholders() {
let t = translator_with_en_fr();
assert_eq!(
t.gettext_fmt("fr", "welcome", &[("name", "Alice")]),
"Bienvenue, Alice !"
);
}
#[test]
fn gettext_falls_back_to_key_on_miss() {
let t = translator_with_en_fr();
assert_eq!(t.gettext("en", "no_such_key"), "no_such_key");
}
#[test]
fn pgettext_uses_context_disambiguated_entry() {
let t = translator_with_en_fr();
assert_eq!(t.pgettext("en", "verb", "save"), "Save");
assert_eq!(t.pgettext("en", "noun", "save"), "Discount");
}
#[test]
fn pgettext_falls_back_to_bare_key_when_context_entry_missing() {
let t = translator_with_en_fr();
assert_eq!(t.pgettext("en", "adjective", "save"), "Save (bare)");
}
#[test]
fn pgettext_fmt_substitutes_placeholders() {
let mut en = HashMap::new();
en.insert("button\u{0004}greet".into(), "Hi, {name}".into());
let t = Translator::new(Locale::new("en")).add_locale(Locale::new("en"), en);
assert_eq!(
t.pgettext_fmt("en", "button", "greet", &[("name", "Bob")]),
"Hi, Bob"
);
}
#[test]
fn ngettext_picks_singular_when_count_is_one() {
let t = translator_with_en_fr();
let s = t.ngettext("en", "count_unread", "count_unread_plural", 1);
assert_eq!(s, "You have 1 unread message.");
}
#[test]
fn ngettext_picks_plural_for_zero_and_many() {
let t = translator_with_en_fr();
let s0 = t.ngettext("en", "count_unread", "count_unread_plural", 0);
let s5 = t.ngettext("en", "count_unread", "count_unread_plural", 5);
assert_eq!(s0, "You have 0 unread messages.");
assert_eq!(s5, "You have 5 unread messages.");
}
#[test]
fn ngettext_works_in_french_locale() {
let t = translator_with_en_fr();
let s1 = t.ngettext("fr", "count_unread", "count_unread_plural", 1);
let s3 = t.ngettext("fr", "count_unread", "count_unread_plural", 3);
assert_eq!(s1, "Vous avez 1 message non lu.");
assert_eq!(s3, "Vous avez 3 messages non lus.");
}
#[test]
fn ngettext_fmt_overlays_extra_placeholders() {
let mut en = HashMap::new();
en.insert(
"item_singular".into(),
"{count} {item} sold to {customer}.".into(),
);
en.insert(
"item_plural".into(),
"{count} {item}s sold to {customer}.".into(),
);
let t = Translator::new(Locale::new("en")).add_locale(Locale::new("en"), en);
let s = t.ngettext_fmt(
"en",
"item_singular",
"item_plural",
2,
&[("item", "book"), ("customer", "Alice")],
);
assert_eq!(s, "2 books sold to Alice.");
}
#[test]
fn plural_category_covers_launch_locales() {
assert_eq!(plural_category("en", 1), "one");
for n in [0, 2, 5, 11, 21, 100] {
assert_eq!(plural_category("en", n), "other", "en {n}");
}
assert_eq!(plural_category("de", 1), "one");
assert_eq!(plural_category("de", 7), "other");
assert_eq!(plural_category("fr", 0), "one");
assert_eq!(plural_category("fr", 1), "one");
assert_eq!(plural_category("fr", 2), "other");
assert_eq!(plural_category("fr-FR", 0), "one"); for n in [0, 1, 2, 5, 100] {
assert_eq!(plural_category("zh-Hans", n), "other", "zh {n}");
assert_eq!(plural_category("ja", n), "other", "ja {n}");
}
assert_eq!(plural_category("pl", 1), "one");
for n in [2, 3, 4, 22, 23, 24] {
assert_eq!(plural_category("pl", n), "few", "pl {n}");
}
for n in [0, 5, 11, 12, 14, 25, 111] {
assert_eq!(plural_category("pl", n), "many", "pl {n}");
}
for n in [1, 21, 31, 101] {
assert_eq!(plural_category("uk", n), "one", "uk {n}");
}
for n in [2, 3, 4, 22, 23, 24] {
assert_eq!(plural_category("uk", n), "few", "uk {n}");
}
for n in [0, 5, 11, 12, 13, 14, 25] {
assert_eq!(plural_category("uk", n), "many", "uk {n}");
}
}
#[test]
fn translate_plural_selects_form_three_way() {
let mut entry = HashMap::new();
entry.insert("one".to_owned(), "Usunięto {count} stronę.".to_owned());
entry.insert("few".to_owned(), "Usunięto {count} strony.".to_owned());
entry.insert("many".to_owned(), "Usunięto {count} stron.".to_owned());
let key = "Deleted {count} page(s).";
let mut pl: HashMap<String, HashMap<String, String>> = HashMap::new();
pl.insert(key.to_owned(), entry);
let t = Translator::new(Locale::new("en")).add_plural_locale(Locale::new("pl"), pl);
let tr = |n: i64| t.translate_plural("pl", key, n, &[("count", &n.to_string())]);
assert_eq!(tr(1), "Usunięto 1 stronę."); assert_eq!(tr(2), "Usunięto 2 strony."); assert_eq!(tr(22), "Usunięto 22 strony."); assert_eq!(tr(5), "Usunięto 5 stron."); assert_eq!(tr(25), "Usunięto 25 stron."); }
#[test]
fn translate_plural_picks_other_when_category_absent() {
let mut entry = HashMap::new();
entry.insert("one".to_owned(), "{count} élément".to_owned());
entry.insert("other".to_owned(), "{count} éléments".to_owned());
let key = "count_items";
let mut fr: HashMap<String, HashMap<String, String>> = HashMap::new();
fr.insert(key.to_owned(), entry);
let t = Translator::new(Locale::new("en")).add_plural_locale(Locale::new("fr"), fr);
assert_eq!(
t.translate_plural("fr", key, 1, &[("count", "1")]),
"1 élément"
);
assert_eq!(
t.translate_plural("fr", key, 9, &[("count", "9")]),
"9 éléments"
);
}
#[test]
fn translate_plural_falls_back_to_scalar_and_source() {
let t = Translator::new(Locale::new("en"));
assert_eq!(
t.translate_plural("en", "Deleted {count} page(s).", 3, &[("count", "3")]),
"Deleted 3 page(s)."
);
let mut en = HashMap::new();
en.insert("flat_msg".to_owned(), "{count} thing(s)".to_owned());
let t = Translator::new(Locale::new("en")).add_locale(Locale::new("en"), en);
assert_eq!(
t.translate_plural("en", "flat_msg", 2, &[("count", "2")]),
"2 thing(s)"
);
}
}