300 lines
8.8 KiB
Rust
300 lines
8.8 KiB
Rust
/// LocaleBundle — loads and caches translation strings.
|
|
///
|
|
/// A bundle holds all translation strings for one locale. Strings are
|
|
/// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle
|
|
/// supports both flat strings and plural forms.
|
|
|
|
use std::collections::HashMap;
|
|
use crate::locale::Locale;
|
|
use crate::plural::{plural_form, PluralForm};
|
|
|
|
/// A single translation value — either a simple string or a plural map.
|
|
#[derive(Debug, Clone)]
|
|
pub enum TranslationValue {
|
|
/// A simple translated string. May contain `{key}` interpolation placeholders.
|
|
Simple(String),
|
|
/// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other".
|
|
Plural(HashMap<String, String>),
|
|
}
|
|
|
|
/// A bundle of translations for a single locale.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocaleBundle {
|
|
pub locale: Locale,
|
|
translations: HashMap<String, TranslationValue>,
|
|
}
|
|
|
|
impl LocaleBundle {
|
|
/// Create an empty bundle for a locale.
|
|
pub fn new(locale: Locale) -> Self {
|
|
Self {
|
|
locale,
|
|
translations: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Insert a simple translation.
|
|
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
|
self.translations.insert(
|
|
key.into(),
|
|
TranslationValue::Simple(value.into()),
|
|
);
|
|
}
|
|
|
|
/// Insert a plural translation.
|
|
pub fn insert_plural(
|
|
&mut self,
|
|
key: impl Into<String>,
|
|
forms: HashMap<String, String>,
|
|
) {
|
|
self.translations
|
|
.insert(key.into(), TranslationValue::Plural(forms));
|
|
}
|
|
|
|
/// Look up a key and return the simple string (no interpolation).
|
|
pub fn get_raw(&self, key: &str) -> Option<&str> {
|
|
match self.translations.get(key)? {
|
|
TranslationValue::Simple(s) => Some(s.as_str()),
|
|
TranslationValue::Plural(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Look up a key with variable interpolation.
|
|
///
|
|
/// Replaces `{name}` placeholders with values from `vars`.
|
|
pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option<String> {
|
|
let raw = match self.translations.get(key)? {
|
|
TranslationValue::Simple(s) => s.clone(),
|
|
TranslationValue::Plural(forms) => {
|
|
// For translate(), use "other" as default
|
|
forms.get("other")?.clone()
|
|
}
|
|
};
|
|
Some(interpolate(&raw, vars))
|
|
}
|
|
|
|
/// Look up a plural key with a count.
|
|
///
|
|
/// Selects the correct plural form for the locale's language and count,
|
|
/// then interpolates `{n}` and any other `vars`.
|
|
pub fn translate_plural(
|
|
&self,
|
|
key: &str,
|
|
count: i64,
|
|
vars: &HashMap<&str, String>,
|
|
) -> Option<String> {
|
|
let forms = match self.translations.get(key)? {
|
|
TranslationValue::Plural(f) => f,
|
|
TranslationValue::Simple(s) => {
|
|
// Fall through: treat the simple string as "other"
|
|
let mut result_vars = vars.clone();
|
|
result_vars.insert("n", count.to_string());
|
|
return Some(interpolate(s, &result_vars));
|
|
}
|
|
};
|
|
|
|
let form = plural_form(&self.locale.language, count);
|
|
let form_key = match form {
|
|
PluralForm::Zero => "zero",
|
|
PluralForm::One => "one",
|
|
PluralForm::Two => "two",
|
|
PluralForm::Few => "few",
|
|
PluralForm::Many => "many",
|
|
PluralForm::Other => "other",
|
|
};
|
|
|
|
let template = forms
|
|
.get(form_key)
|
|
.or_else(|| forms.get("other"))?;
|
|
|
|
let mut result_vars = vars.clone();
|
|
result_vars.insert("n", count.to_string());
|
|
Some(interpolate(template, &result_vars))
|
|
}
|
|
|
|
/// Number of translations loaded.
|
|
pub fn len(&self) -> usize {
|
|
self.translations.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.translations.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Replace `{key}` placeholders in `template` with values from `vars`.
|
|
fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String {
|
|
let mut result = template.to_string();
|
|
for (key, value) in vars {
|
|
result = result.replace(&format!("{{{}}}", key), value);
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Load a bundle from a TOML string.
|
|
///
|
|
/// Format:
|
|
/// ```toml
|
|
/// [profile]
|
|
/// follow = "Follow"
|
|
/// followers = { one = "{n} Follower", other = "{n} Followers" }
|
|
/// ```
|
|
pub fn load_toml(locale: Locale, toml_str: &str) -> Result<LocaleBundle, String> {
|
|
let value: toml::Value = toml::from_str(toml_str)
|
|
.map_err(|e| format!("TOML parse error: {}", e))?;
|
|
|
|
let mut bundle = LocaleBundle::new(locale);
|
|
|
|
if let toml::Value::Table(table) = value {
|
|
load_table(&mut bundle, &table, "");
|
|
}
|
|
|
|
Ok(bundle)
|
|
}
|
|
|
|
fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) {
|
|
for (key, value) in table {
|
|
let full_key = if prefix.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
format!("{}.{}", prefix, key)
|
|
};
|
|
|
|
match value {
|
|
toml::Value::String(s) => {
|
|
bundle.insert(full_key, s.clone());
|
|
}
|
|
toml::Value::Table(inner) => {
|
|
// Check if it's a plural table (has "one", "other", etc.)
|
|
let is_plural = inner.contains_key("one")
|
|
|| inner.contains_key("other")
|
|
|| inner.contains_key("zero")
|
|
|| inner.contains_key("few")
|
|
|| inner.contains_key("many");
|
|
|
|
if is_plural {
|
|
let mut forms = HashMap::new();
|
|
for (form, form_val) in inner {
|
|
if let toml::Value::String(s) = form_val {
|
|
forms.insert(form.clone(), s.clone());
|
|
}
|
|
}
|
|
bundle.insert_plural(full_key, forms);
|
|
} else {
|
|
// Nested namespace
|
|
load_table(bundle, inner, &full_key);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn en_bundle() -> LocaleBundle {
|
|
let mut b = LocaleBundle::new(Locale::en_us());
|
|
b.insert("profile.follow", "Follow");
|
|
b.insert("profile.bio", "Bio");
|
|
let mut forms = HashMap::new();
|
|
forms.insert("one".to_string(), "{n} Follower".to_string());
|
|
forms.insert("other".to_string(), "{n} Followers".to_string());
|
|
b.insert_plural("profile.followers", forms);
|
|
b
|
|
}
|
|
|
|
#[test]
|
|
fn get_raw_simple() {
|
|
let b = en_bundle();
|
|
assert_eq!(b.get_raw("profile.follow"), Some("Follow"));
|
|
}
|
|
|
|
#[test]
|
|
fn get_raw_missing() {
|
|
let b = en_bundle();
|
|
assert_eq!(b.get_raw("nonexistent.key"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn translate_simple() {
|
|
let b = en_bundle();
|
|
let vars = HashMap::new();
|
|
assert_eq!(
|
|
b.translate("profile.follow", &vars),
|
|
Some("Follow".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn translate_plural_one() {
|
|
let b = en_bundle();
|
|
let vars = HashMap::new();
|
|
assert_eq!(
|
|
b.translate_plural("profile.followers", 1, &vars),
|
|
Some("1 Follower".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn translate_plural_many() {
|
|
let b = en_bundle();
|
|
let vars = HashMap::new();
|
|
assert_eq!(
|
|
b.translate_plural("profile.followers", 42, &vars),
|
|
Some("42 Followers".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interpolation_replaces_placeholder() {
|
|
let mut b = LocaleBundle::new(Locale::en_us());
|
|
b.insert("greeting", "Hello, {name}!");
|
|
let mut vars = HashMap::new();
|
|
vars.insert("name", "Alice".to_string());
|
|
assert_eq!(
|
|
b.translate("greeting", &vars),
|
|
Some("Hello, Alice!".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_len() {
|
|
let b = en_bundle();
|
|
assert_eq!(b.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn load_toml_simple() {
|
|
let toml = r#"
|
|
[profile]
|
|
follow = "Follow"
|
|
bio = "Bio"
|
|
"#;
|
|
let bundle = load_toml(Locale::en_us(), toml).unwrap();
|
|
assert_eq!(bundle.get_raw("profile.follow"), Some("Follow"));
|
|
assert_eq!(bundle.get_raw("profile.bio"), Some("Bio"));
|
|
}
|
|
|
|
#[test]
|
|
fn load_toml_plural() {
|
|
let toml = r#"
|
|
[profile]
|
|
followers = { one = "{n} Follower", other = "{n} Followers" }
|
|
"#;
|
|
let bundle = load_toml(Locale::en_us(), toml).unwrap();
|
|
let vars = HashMap::new();
|
|
assert_eq!(
|
|
bundle.translate_plural("profile.followers", 1, &vars),
|
|
Some("1 Follower".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_toml_invalid() {
|
|
let result = load_toml(Locale::en_us(), "not valid toml %%%");
|
|
assert!(result.is_err());
|
|
}
|
|
}
|