use serde_json::Value;
use std::{
collections::HashMap,
fmt,
fs::File,
io::{self, Read},
path::Path,
};
#[derive(Debug)]
pub enum I18nError {
Io(io::Error),
Json(serde_json::Error),
InvalidFormat(String),
UnknownFallbackLanguage(String),
}
impl fmt::Display for I18nError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "failed to read translation source: {err}"),
Self::Json(err) => write!(f, "failed to parse translation JSON: {err}"),
Self::InvalidFormat(details) => write!(f, "invalid translation format: {details}"),
Self::UnknownFallbackLanguage(lang) => {
write!(f, "fallback language `{lang}` was not found in translations")
}
}
}
}
impl std::error::Error for I18nError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Json(err) => Some(err),
_ => None,
}
}
}
impl From<io::Error> for I18nError {
fn from(err: io::Error) -> Self {
Self::Io(err)
}
}
impl From<serde_json::Error> for I18nError {
fn from(err: serde_json::Error) -> Self {
Self::Json(err)
}
}
#[derive(Debug, Clone)]
pub struct Translator {
fallback_language: Option<String>,
translations: HashMap<String, HashMap<String, String>>,
}
impl Translator {
pub fn builder() -> TranslatorBuilder {
TranslatorBuilder::default()
}
pub fn translate<'a>(&'a self, language: &str, key: &str) -> Option<&'a str> {
self.translations
.get(language)
.and_then(|catalog| catalog.get(key).map(|value| value.as_str()))
}
pub fn translate_with_fallback<'a>(&'a self, language: &str, key: &str) -> Option<&'a str> {
self.translate(language, key)
.or_else(|| self.fallback_language.as_deref().and_then(|fallback| self.translate(fallback, key)))
}
pub fn available_languages(&self) -> Vec<&str> {
self.translations.keys().map(|key| key.as_str()).collect()
}
}
#[derive(Default)]
pub struct TranslatorBuilder {
fallback_language: Option<String>,
}
impl TranslatorBuilder {
pub fn fallback_language(mut self, language: impl Into<String>) -> Self {
self.fallback_language = Some(language.into());
self
}
pub fn load_from_path<P>(self, path: P) -> Result<Translator, I18nError>
where
P: AsRef<Path>,
{
let file = File::open(path)?;
self.load_from_reader(file)
}
pub fn load_from_reader<R>(self, mut reader: R) -> Result<Translator, I18nError>
where
R: Read,
{
let mut buf = String::new();
reader.read_to_string(&mut buf)?;
self.load_from_str(&buf)
}
pub fn load_from_str(self, json: &str) -> Result<Translator, I18nError> {
let raw: HashMap<String, Value> = serde_json::from_str(json)?;
build_translator(raw, self.fallback_language)
}
}
fn build_translator(
raw: HashMap<String, Value>,
fallback_language: Option<String>,
) -> Result<Translator, I18nError> {
let mut translations = HashMap::with_capacity(raw.len());
for (language, value) in raw {
let mut catalog = HashMap::new();
flatten_value("", &value, &mut catalog)?;
translations.insert(language, catalog);
}
if let Some(ref language) = fallback_language {
if !translations.contains_key(language) {
return Err(I18nError::UnknownFallbackLanguage(language.clone()));
}
}
Ok(Translator {
fallback_language,
translations,
})
}
fn flatten_value(prefix: &str, value: &Value, out: &mut HashMap<String, String>) -> Result<(), I18nError> {
match value {
Value::String(text) => {
if prefix.is_empty() {
return Err(I18nError::InvalidFormat(
"language entries must be objects containing key/value pairs".to_owned(),
));
}
out.insert(prefix.to_owned(), text.clone());
Ok(())
}
Value::Object(map) => {
for (key, nested) in map {
let next_prefix = if prefix.is_empty() {
key.to_owned()
} else {
format!("{prefix}.{key}")
};
flatten_value(&next_prefix, nested, out)?;
}
Ok(())
}
other => Err(I18nError::InvalidFormat(format!(
"expected string or object but found {other:?} at `{prefix}`"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
static SAMPLE_JSON: &str = r#"{
"en": {
"greeting": {
"welcome": "Welcome",
"farewell": "Goodbye"
},
"plain": "Simple"
},
"fr": {
"greeting": {
"welcome": "Bienvenue"
},
"plain": "Simple"
}
}"#;
#[test]
fn loads_translations_from_str() {
let translator = Translator::builder().load_from_str(SAMPLE_JSON).unwrap();
assert_eq!(
translator.translate("en", "greeting.welcome"),
Some("Welcome")
);
assert_eq!(translator.translate("fr", "greeting.welcome"), Some("Bienvenue"));
assert_eq!(translator.translate("fr", "greeting.farewell"), None);
}
#[test]
fn uses_fallback_language() {
let translator = Translator::builder()
.fallback_language("en")
.load_from_str(SAMPLE_JSON)
.unwrap();
assert_eq!(
translator.translate_with_fallback("fr", "greeting.farewell"),
Some("Goodbye")
);
}
#[test]
fn rejects_unknown_fallback() {
let err = Translator::builder()
.fallback_language("es")
.load_from_str(SAMPLE_JSON)
.unwrap_err();
match err {
I18nError::UnknownFallbackLanguage(lang) => assert_eq!(lang, "es"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn returns_available_languages() {
let translator = Translator::builder().load_from_str(SAMPLE_JSON).unwrap();
let mut languages = translator.available_languages();
languages.sort();
assert_eq!(languages, vec!["en", "fr"]);
}
}