use parking_lot::RwLock;
use serde_json::{Map, Value};
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static CURRENT_LOCALE: RefCell<Option<String>> = const { RefCell::new(None) };
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum I18nError {
#[error("translation parse error: {0}")]
Parse(String),
}
#[derive(Debug)]
pub struct I18n {
default_locale: RwLock<String>,
translations: RwLock<HashMap<String, Value>>,
}
impl I18n {
#[must_use]
pub fn new(locale: impl Into<String>) -> Self {
Self {
default_locale: RwLock::new(locale.into()),
translations: RwLock::new(HashMap::new()),
}
}
pub fn load_translations(&self, toml_str: &str) -> Result<(), I18nError> {
let parsed: toml::Value =
toml::from_str(toml_str).map_err(|error| I18nError::Parse(error.to_string()))?;
let json =
serde_json::to_value(parsed).map_err(|error| I18nError::Parse(error.to_string()))?;
let Value::Object(locales) = json else {
return Err(I18nError::Parse(String::from(
"top-level translation value must be a table",
)));
};
let mut translations = self.translations.write();
for (locale, value) in locales {
match translations.get_mut(&locale) {
Some(existing) => merge_value(existing, &value),
None => {
translations.insert(locale, value);
}
}
}
Ok(())
}
pub fn set_locale(&self, locale: impl Into<String>) {
let locale = locale.into();
CURRENT_LOCALE.with(|current| *current.borrow_mut() = Some(locale));
}
#[must_use]
pub fn locale(&self) -> String {
CURRENT_LOCALE.with(|current| {
current
.borrow()
.clone()
.unwrap_or_else(|| self.default_locale.read().clone())
})
}
#[must_use]
pub fn t(&self, key: &str) -> String {
self.translate(key, None, None)
}
#[must_use]
pub fn t_count(&self, key: &str, count: usize) -> String {
self.translate(key, Some(count), None)
}
#[must_use]
pub fn t_default(&self, key: &str, default: &str) -> String {
self.translate(key, None, Some(default))
}
#[must_use]
pub fn translate(&self, key: &str, count: Option<usize>, default: Option<&str>) -> String {
let locale = self.locale();
let translations = self.translations.read();
let value = translations
.get(&locale)
.and_then(|root| lookup_key(root, key))
.and_then(|value| resolve_pluralization(value, count));
match value {
Some(value) => render_value(value, count),
None => default.unwrap_or(key).to_owned(),
}
}
}
#[cfg(test)]
pub(crate) fn reset_locale() {
CURRENT_LOCALE.with(|current| *current.borrow_mut() = None);
}
fn lookup_key<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let mut current = value;
for segment in key.split('.') {
current = current.as_object()?.get(segment)?;
}
Some(current)
}
fn resolve_pluralization(value: &Value, count: Option<usize>) -> Option<&Value> {
if let Some(count) = count
&& let Some(map) = value.as_object()
{
let key = if count == 1 { "one" } else { "other" };
return map.get(key).or_else(|| map.get("other"));
}
Some(value)
}
fn render_value(value: &Value, count: Option<usize>) -> String {
match value {
Value::String(text) => count
.map(|count| text.replace("%{count}", &count.to_string()))
.unwrap_or_else(|| text.clone()),
_ => value.to_string(),
}
}
fn merge_value(existing: &mut Value, incoming: &Value) {
match (existing, incoming) {
(Value::Object(existing), Value::Object(incoming)) => {
for (key, value) in incoming {
match existing.get_mut(key) {
Some(existing_value) => merge_value(existing_value, value),
None => {
existing.insert(key.clone(), value.clone());
}
}
}
}
(existing, incoming) => *existing = incoming.clone(),
}
}
#[cfg(test)]
mod tests {
use super::{I18n, I18nError, reset_locale};
use std::thread;
const TRANSLATIONS: &str = r#"
[en.errors.messages]
blank = "can't be blank"
[en.items]
one = "%{count} item"
other = "%{count} items"
[en.meta]
version = 1
[es.errors.messages]
blank = "no puede estar en blanco"
[es.greeting]
hello = "hola"
"#;
fn run_isolated<R>(test: impl FnOnce() -> R + Send + 'static) -> R
where
R: Send + 'static,
{
match thread::spawn(test).join() {
Ok(result) => result,
Err(payload) => std::panic::resume_unwind(payload),
}
}
#[test]
fn locale_defaults_to_constructor_value() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
assert_eq!(i18n.locale(), "en");
});
}
#[test]
fn load_translations_parses_toml_content() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
assert_eq!(i18n.t("errors.messages.blank"), "can't be blank");
});
}
#[test]
fn dot_notation_traverses_nested_translation_keys() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
assert_eq!(i18n.t("errors.messages.blank"), "can't be blank");
});
}
#[test]
fn missing_key_returns_the_key() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
assert_eq!(i18n.t("missing.key"), "missing.key");
});
}
#[test]
fn default_value_is_used_when_key_is_missing() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
assert_eq!(i18n.t_default("missing.key", "fallback"), "fallback");
});
}
#[test]
fn pluralization_uses_one_for_singular_counts() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
assert_eq!(i18n.t_count("items", 1), "1 item");
});
}
#[test]
fn pluralization_uses_other_for_plural_counts() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
assert_eq!(i18n.t_count("items", 3), "3 items");
});
}
#[test]
fn set_locale_overrides_the_current_thread_locale() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
i18n.set_locale("es");
assert_eq!(i18n.locale(), "es");
assert_eq!(i18n.t("errors.messages.blank"), "no puede estar en blanco");
});
}
#[test]
fn locale_override_is_thread_local() {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
i18n.set_locale("es");
let child_value = run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
i18n.t("errors.messages.blank")
});
assert_eq!(child_value, "can't be blank");
assert_eq!(i18n.t("errors.messages.blank"), "no puede estar en blanco");
}
#[test]
fn later_translation_loads_are_merged() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations("[en.errors.messages]\nblank = \"can't be blank\"")
.expect("translations should load");
i18n.load_translations("[en.greeting]\nhello = \"hello\"")
.expect("translations should load");
assert_eq!(i18n.t("errors.messages.blank"), "can't be blank");
assert_eq!(i18n.t("greeting.hello"), "hello");
});
}
#[test]
fn non_string_values_are_rendered() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
i18n.load_translations(TRANSLATIONS)
.expect("translations should load");
assert_eq!(i18n.t("meta.version"), "1");
});
}
#[test]
fn missing_pluralization_uses_default_when_provided() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
assert_eq!(
i18n.translate("items", Some(2), Some("fallback")),
"fallback"
);
});
}
#[test]
fn invalid_toml_returns_a_typed_error() {
run_isolated(|| {
reset_locale();
let i18n = I18n::new("en");
let error = i18n
.load_translations("[en\nhello = \"world\"")
.expect_err("invalid toml should fail");
assert!(matches!(error, I18nError::Parse(_)));
});
}
}