use serde::Serialize;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::OnceLock;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Text {
Literal(Cow<'static, str>),
Key {
key: Cow<'static, str>,
fallback: Option<Cow<'static, str>>,
args: Vec<(&'static str, Text)>,
},
}
impl Text {
pub fn key(key: impl Into<Cow<'static, str>>) -> Self {
Text::Key { key: key.into(), fallback: None, args: Vec::new() }
}
pub fn key_or(key: impl Into<Cow<'static, str>>, fallback: impl Into<Cow<'static, str>>) -> Self {
Text::Key { key: key.into(), fallback: Some(fallback.into()), args: Vec::new() }
}
pub fn arg(mut self, name: &'static str, value: impl Into<Text>) -> Self {
if let Text::Key { args, .. } = &mut self {
args.push((name, value.into()));
}
self
}
pub fn english(&self) -> String {
I18n::builtin().text("en", self)
}
}
impl From<&'static str> for Text {
fn from(s: &'static str) -> Self {
Text::Literal(Cow::Borrowed(s))
}
}
impl From<String> for Text {
fn from(s: String) -> Self {
Text::Literal(Cow::Owned(s))
}
}
impl Serialize for Text {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.english())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NumberFormat {
pub decimal: char,
pub group: char,
pub symbol_first: bool,
}
impl NumberFormat {
pub fn for_locale(locale: &str) -> Self {
match primary(locale) {
"tr" | "de" | "es" | "it" | "nl" | "pt" | "id" => {
NumberFormat { decimal: ',', group: '.', symbol_first: false }
}
"fr" | "ru" | "pl" | "cs" | "sv" | "fi" | "nb" | "uk" => {
NumberFormat { decimal: ',', group: '\u{202f}', symbol_first: false }
}
_ => NumberFormat { decimal: '.', group: ',', symbol_first: true },
}
}
pub fn percent_first(locale: &str) -> bool {
matches!(primary(locale), "tr" | "eu")
}
}
fn primary(locale: &str) -> &str {
locale.split(['-', '_']).next().unwrap_or(locale)
}
#[derive(Clone, Debug)]
pub struct I18n {
default: String,
locales: Vec<String>,
catalogs: HashMap<String, HashMap<String, String>>,
}
impl I18n {
pub fn new(default: &str) -> Self {
let mut i18n = I18n { default: default.to_string(), locales: Vec::new(), catalogs: HashMap::new() };
i18n.merge("en", include_str!("../locales/en.json"), false).expect("built in catalog");
i18n.merge("tr", include_str!("../locales/tr.json"), false).expect("built in catalog");
i18n.locales.clear();
i18n.locales.push(default.to_string());
i18n
}
pub fn builtin() -> &'static I18n {
static BUILTIN: OnceLock<I18n> = OnceLock::new();
BUILTIN.get_or_init(|| I18n::new("en"))
}
pub fn catalog(mut self, locale: &str, json: &str) -> Result<Self, String> {
self.merge(locale, json, true)?;
Ok(self)
}
pub fn catalogs(mut self, catalogs: &[(&str, &str)]) -> Result<Self, String> {
for (locale, json) in catalogs {
self.merge(locale, json, true)?;
}
Ok(self)
}
fn merge(&mut self, locale: &str, json: &str, offered: bool) -> Result<(), String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("catalog `{locale}` is not valid JSON: {e}"))?;
let catalog = self.catalogs.entry(locale.to_string()).or_default();
flatten("", &value, catalog).map_err(|e| format!("catalog `{locale}`: {e}"))?;
if offered && !self.locales.iter().any(|l| l == locale) {
self.locales.push(locale.to_string());
}
Ok(())
}
pub fn default_locale(&self) -> &str {
&self.default
}
pub fn locales(&self) -> &[String] {
&self.locales
}
pub fn lookup(&self, locale: &str, key: &str) -> Option<&str> {
[locale, primary(locale), self.default.as_str()]
.into_iter()
.find_map(|l| self.catalogs.get(l).and_then(|c| c.get(key)))
.map(String::as_str)
}
pub fn text(&self, locale: &str, text: &Text) -> String {
match text {
Text::Literal(s) => s.to_string(),
Text::Key { key, fallback, args } => {
let template = self.lookup(locale, key).or(fallback.as_deref()).unwrap_or(key);
let mut out = template.to_string();
for (name, value) in args {
out = out.replace(&format!("{{{name}}}"), &self.text(locale, value));
}
out
}
}
}
pub fn negotiate(&self, cookie: Option<&str>, accept_language: Option<&str>) -> String {
if let Some(c) = cookie.filter(|c| self.locales.iter().any(|l| l == c)) {
return c.to_string();
}
let mut wanted: Vec<(f32, &str)> = accept_language
.unwrap_or("")
.split(',')
.filter_map(|part| {
let mut pieces = part.trim().split(';');
let tag = pieces.next()?.trim();
let q =
pieces.find_map(|p| p.trim().strip_prefix("q=").and_then(|q| q.parse::<f32>().ok())).unwrap_or(1.0);
(!tag.is_empty() && q > 0.0).then_some((q, tag))
})
.collect();
wanted.sort_by(|a, b| b.0.total_cmp(&a.0));
for (_, tag) in wanted {
if let Some(l) = self.locales.iter().find(|l| l.eq_ignore_ascii_case(tag)) {
return l.clone();
}
if let Some(l) = self.locales.iter().find(|l| primary(l).eq_ignore_ascii_case(primary(tag))) {
return l.clone();
}
}
self.default.clone()
}
pub fn missing_keys(&self) -> Vec<(String, String)> {
let Some(base) = self.catalogs.get(&self.default) else { return Vec::new() };
let mut missing = Vec::new();
for locale in self.locales.iter().filter(|l| **l != self.default) {
let catalog = self.catalogs.get(locale);
for key in base.keys() {
if !catalog.is_some_and(|c| c.contains_key(key)) {
missing.push((locale.clone(), key.clone()));
}
}
}
missing.sort();
missing
}
}
fn flatten(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) -> Result<(), String> {
match value {
serde_json::Value::Object(map) => {
for (k, v) in map {
let key = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
flatten(&key, v, out)?;
}
Ok(())
}
serde_json::Value::String(s) => {
out.insert(prefix.to_string(), s.clone());
Ok(())
}
_ => Err(format!("`{prefix}` must be a string or an object")),
}
}