use std::collections::BTreeMap;
mod format;
mod plural;
pub mod policy;
#[cfg(test)]
mod tests;
pub use format::{MessageValue, MessageValues, format_message};
pub use plural::PluralCategory;
pub const LOCALES: [Locale; 5] = [Locale::En, Locale::Ru, Locale::Vi, Locale::Fr, Locale::De];
pub const DEFAULT_LOCALE: Locale = Locale::En;
pub type Messages = BTreeMap<String, String>;
impl Locale {
pub fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::Ru => "ru",
Self::Vi => "vi",
Self::Fr => "fr",
Self::De => "de",
}
}
pub fn label(self) -> &'static str {
match self {
Self::En => "English",
Self::Ru => "Русский",
Self::Vi => "Tiếng Việt",
Self::Fr => "Français",
Self::De => "Deutsch",
}
}
pub fn parse(value: &str) -> Option<Self> {
LOCALES.into_iter().find(|l| l.code() == value)
}
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
impl std::str::FromStr for Locale {
type Err = UnknownLocale;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s).ok_or_else(|| UnknownLocale(s.to_owned()))
}
}
impl std::fmt::Display for UnknownLocale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unknown locale {:?} — EV publishes en, ru, vi, fr, de", self.0)
}
}
impl std::error::Error for UnknownLocale {}
impl Translator {
pub fn new(messages: Messages, locale: Locale) -> Self {
Self { messages, locale }
}
pub fn locale(&self) -> Locale {
self.locale
}
pub fn t(&self, key: &str, en: &str) -> String {
self.render(key, en, &MessageValues::new())
}
pub fn tv(&self, key: &str, en: &str, values: &MessageValues) -> String {
self.render(key, en, values)
}
pub fn count(&self, key: &str, en: &str, name: &str, n: f64) -> String {
let mut values = MessageValues::new();
values.insert(name.to_owned(), MessageValue::Num(n));
self.render(key, en, &values)
}
fn render(&self, key: &str, en: &str, values: &MessageValues) -> String {
let pattern = if self.locale == DEFAULT_LOCALE {
en
} else {
self.messages.get(key).map_or(en, String::as_str)
};
format_message(pattern, self.locale, values)
}
}
#[cfg(feature = "i18n")]
#[macro_export]
macro_rules! t {
($tr:expr, $key:literal, $en:literal) => {{
$crate::__i18n_register!($key, $en);
$tr.t($key, $en)
}};
($tr:expr, $key:literal, $en:literal, $($name:ident = $value:expr),+ $(,)?) => {{
$crate::__i18n_register!($key, $en);
let mut values = $crate::i18n::MessageValues::new();
$(values.insert(stringify!($name).to_owned(), $crate::i18n::MessageValue::from($value));)+
$tr.tv($key, $en, &values)
}};
}
#[cfg(all(feature = "i18n", not(target_arch = "wasm32")))]
#[doc(hidden)]
#[macro_export]
macro_rules! __i18n_register {
($key:literal, $en:literal) => {
$crate::i18n::inventory::submit! { $crate::i18n::Source { key: $key, en: $en } }
};
}
#[cfg(all(feature = "i18n", target_arch = "wasm32"))]
#[doc(hidden)]
#[macro_export]
macro_rules! __i18n_register {
($key:literal, $en:literal) => {};
}
#[cfg(not(target_arch = "wasm32"))]
pub use inventory;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Translator {
messages: Messages,
locale: Locale,
}
pub fn negotiate(header: Option<&str>, locales: &[Locale]) -> Locale {
let Some(header) = header else {
return DEFAULT_LOCALE;
};
let mut ranked: Vec<(String, f64)> = header
.split(',')
.filter_map(|part| {
let mut params = part.trim().split(';');
let tag = params.next().unwrap_or("").trim().to_ascii_lowercase();
let quality = params
.map(str::trim)
.find_map(|p| p.strip_prefix("q="))
.map_or(1.0, |q| q.parse::<f64>().unwrap_or(0.0));
(!tag.is_empty() && quality > 0.0).then_some((tag, quality))
})
.collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
for (tag, _) in ranked {
let base = tag.split('-').next().unwrap_or("");
if let Some(hit) = locales.iter().find(|l| l.code() == tag || l.code() == base) {
return *hit;
}
if tag == "*" {
return DEFAULT_LOCALE;
}
}
DEFAULT_LOCALE
}
pub fn locale_alternates(path: &str, locales: &[Locale]) -> Vec<(Locale, String)> {
locales.iter().map(|&l| (l, locale_path(l, path))).collect()
}
pub fn split_locale_path(pathname: &str) -> (Locale, String) {
let clean = if pathname.starts_with('/') { pathname.to_owned() } else { format!("/{pathname}") };
let after = &clean[1..];
let (head, rest) = match after.find('/') {
Some(idx) => (&after[..idx], &after[idx..]),
None => (after, ""),
};
match Locale::parse(head) {
Some(locale) if locale != DEFAULT_LOCALE => (locale, if rest.is_empty() { "/".to_owned() } else { rest.to_owned() }),
_ => (DEFAULT_LOCALE, clean),
}
}
pub fn locale_path(locale: Locale, path: &str) -> String {
let clean = if path.starts_with('/') { path.to_owned() } else { format!("/{path}") };
if locale == DEFAULT_LOCALE {
return clean;
}
if clean == "/" { format!("/{locale}") } else { format!("/{locale}{clean}") }
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnknownLocale(pub String);
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Locale {
#[default]
En,
Ru,
Vi,
Fr,
De,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Source {
pub key: &'static str,
pub en: &'static str,
}
#[cfg(not(target_arch = "wasm32"))]
inventory::collect!(Source);
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KeyConflict {
pub key: String,
pub first: String,
pub second: String,
}
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Display for KeyConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?} is registered as {:?} and as {:?}", self.key, self.first, self.second)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl std::error::Error for KeyConflict {}
#[cfg(not(target_arch = "wasm32"))]
pub fn catalogue() -> Result<Messages, Vec<KeyConflict>> {
let mut messages = Messages::new();
let mut conflicts = Vec::new();
for source in inventory::iter::<Source> {
match messages.insert(source.key.to_owned(), source.en.to_owned()) {
Some(first) if first != source.en => conflicts.push(KeyConflict {
key: source.key.to_owned(),
first,
second: source.en.to_owned(),
}),
_ => {}
}
}
if conflicts.is_empty() { Ok(messages) } else { Err(conflicts) }
}