Skip to main content

teaql_runtime/
i18n.rs

1use std::collections::BTreeMap;
2use std::sync::{Arc, OnceLock};
3
4use teaql_core::Value;
5
6use crate::{CheckResult, CheckRule, Language, ObjectLocation, RuntimeError};
7
8const BUILTIN_CATALOG_JSON: &str = include_str!("builtin-i18n-v1.json");
9const ALLOWED_ARGUMENTS: [&str; 4] = ["location", "system", "input", "input_len"];
10
11#[derive(Debug, Clone, Default)]
12struct LocaleCatalog {
13    messages: BTreeMap<String, String>,
14    vocabulary: BTreeMap<String, String>,
15}
16
17/// Immutable TeaQL internationalization catalog.
18#[derive(Debug, Clone)]
19pub struct I18nCatalog {
20    default_locale: String,
21    locales: BTreeMap<String, LocaleCatalog>,
22    fallback: Option<Arc<I18nCatalog>>,
23}
24
25impl I18nCatalog {
26    pub fn from_json(source: &str) -> Result<Self, RuntimeError> {
27        let mut catalog = Self::parse(source)?;
28        catalog.fallback = Some(Self::builtin().clone());
29        Ok(catalog)
30    }
31
32    fn parse(source: &str) -> Result<Self, RuntimeError> {
33        let root: serde_json::Value = serde_json::from_str(source)
34            .map_err(|error| RuntimeError::Language(format!("invalid i18n catalog: {error}")))?;
35        let object = root.as_object().ok_or_else(|| {
36            RuntimeError::Language("i18n catalog root must be an object".to_owned())
37        })?;
38        if object.len() != 3
39            || !["schema", "defaultLocale", "locales"]
40                .iter()
41                .all(|key| object.contains_key(*key))
42        {
43            return Err(RuntimeError::Language(
44                "i18n catalog root must contain only schema, defaultLocale and locales".to_owned(),
45            ));
46        }
47        if object.get("schema").and_then(serde_json::Value::as_str) != Some("teaql.i18n/v1") {
48            return Err(RuntimeError::Language(
49                "unsupported i18n catalog schema".to_owned(),
50            ));
51        }
52        let default_locale = object
53            .get("defaultLocale")
54            .and_then(serde_json::Value::as_str)
55            .ok_or_else(|| RuntimeError::Language("missing i18n defaultLocale".to_owned()))?;
56        let default_language = Language::from_code(default_locale).ok_or_else(|| {
57            RuntimeError::Language(format!("unsupported i18n defaultLocale: {default_locale}"))
58        })?;
59        let locale_values = object
60            .get("locales")
61            .and_then(serde_json::Value::as_object)
62            .ok_or_else(|| RuntimeError::Language("missing i18n locales".to_owned()))?;
63        let mut locales = BTreeMap::new();
64        for (locale_code, value) in locale_values {
65            let language = Language::from_code(locale_code).ok_or_else(|| {
66                RuntimeError::Language(format!("unsupported i18n locale: {locale_code}"))
67            })?;
68            let canonical = language.code().to_owned();
69            if canonical != *locale_code {
70                return Err(RuntimeError::Language(format!(
71                    "i18n locale keys must be canonical: {locale_code}"
72                )));
73            }
74            let entry = value.as_object().ok_or_else(|| {
75                RuntimeError::Language(format!("i18n locale {locale_code} must be an object"))
76            })?;
77            if entry.len() != 2
78                || !["messages", "vocabulary"]
79                    .iter()
80                    .all(|key| entry.contains_key(*key))
81            {
82                return Err(RuntimeError::Language(format!(
83                    "i18n locale {locale_code} must contain only messages and vocabulary"
84                )));
85            }
86            let messages = parse_entries(entry.get("messages"), locale_code, "messages", true)?;
87            let vocabulary =
88                parse_entries(entry.get("vocabulary"), locale_code, "vocabulary", false)?;
89            if locales
90                .insert(
91                    canonical,
92                    LocaleCatalog {
93                        messages,
94                        vocabulary,
95                    },
96                )
97                .is_some()
98            {
99                return Err(RuntimeError::Language(format!(
100                    "duplicate canonical i18n locale: {locale_code}"
101                )));
102            }
103        }
104        if !locales.contains_key(default_language.code()) {
105            return Err(RuntimeError::Language(
106                "i18n catalog does not contain its default locale".to_owned(),
107            ));
108        }
109        Ok(Self {
110            default_locale: default_language.code().to_owned(),
111            locales,
112            fallback: None,
113        })
114    }
115
116    pub fn builtin() -> &'static Arc<Self> {
117        static BUILTIN: OnceLock<Arc<I18nCatalog>> = OnceLock::new();
118        BUILTIN.get_or_init(|| {
119            Arc::new(
120                I18nCatalog::parse(BUILTIN_CATALOG_JSON)
121                    .expect("embedded TeaQL i18n catalog must be valid"),
122            )
123        })
124    }
125
126    pub fn message(&self, language: Language, key: &str) -> String {
127        self.lookup(language.code(), "messages", key)
128            .unwrap_or_else(|| key.to_owned())
129    }
130
131    pub fn vocabulary(&self, language: Language, key: &str) -> String {
132        self.lookup(language.code(), "vocabulary", key)
133            .unwrap_or_else(|| key.to_owned())
134    }
135
136    pub fn translate_check_result(&self, language: Language, result: &CheckResult) -> String {
137        let key = match result.rule {
138            CheckRule::Required => "checker.required",
139            CheckRule::InvalidType => "checker.invalidType",
140            CheckRule::Min => "checker.min",
141            CheckRule::Max => "checker.max",
142            CheckRule::MinStringLength => "checker.minLength",
143            CheckRule::MaxStringLength => "checker.maxLength",
144            CheckRule::ContextRootMissing => "checker.contextRootMissing",
145            CheckRule::ContextRootMismatch => "checker.contextRootMismatch",
146        };
147        let location = translate_location(&result.location);
148        let system = result
149            .system_value
150            .as_ref()
151            .map(format_value)
152            .unwrap_or_else(|| "-".to_owned());
153        let input = result
154            .input_value
155            .as_ref()
156            .map(format_value)
157            .unwrap_or_else(|| "-".to_owned());
158        let input_len = result
159            .input_value
160            .as_ref()
161            .and_then(|value| match value {
162                Value::Text(value) => Some(value.chars().count()),
163                _ => None,
164            })
165            .unwrap_or(0)
166            .to_string();
167        render_template(
168            &self.message(language, key),
169            &[
170                ("location", location.as_str()),
171                ("system", system.as_str()),
172                ("input", input.as_str()),
173                ("input_len", input_len.as_str()),
174            ],
175        )
176    }
177
178    fn lookup(&self, locale: &str, namespace: &str, key: &str) -> Option<String> {
179        self.lookup_exact(locale, namespace, key)
180            .or_else(|| {
181                self.fallback
182                    .as_ref()
183                    .and_then(|fallback| fallback.lookup_exact(locale, namespace, key))
184            })
185            .or_else(|| self.lookup_exact(&self.default_locale, namespace, key))
186            .or_else(|| {
187                self.fallback.as_ref().and_then(|fallback| {
188                    fallback.lookup_exact(&fallback.default_locale, namespace, key)
189                })
190            })
191    }
192
193    fn lookup_exact(&self, locale: &str, namespace: &str, key: &str) -> Option<String> {
194        let catalog = self.locales.get(locale)?;
195        match namespace {
196            "messages" => catalog.messages.get(key).cloned(),
197            "vocabulary" => catalog.vocabulary.get(key).cloned(),
198            _ => None,
199        }
200    }
201}
202
203fn parse_entries(
204    value: Option<&serde_json::Value>,
205    locale: &str,
206    namespace: &str,
207    validate_placeholders: bool,
208) -> Result<BTreeMap<String, String>, RuntimeError> {
209    let entries = value
210        .and_then(serde_json::Value::as_object)
211        .ok_or_else(|| {
212            RuntimeError::Language(format!("i18n {locale}.{namespace} must be an object"))
213        })?;
214    let mut parsed = BTreeMap::new();
215    for (key, value) in entries {
216        let text = value
217            .as_str()
218            .filter(|text| !text.is_empty())
219            .ok_or_else(|| {
220                RuntimeError::Language(format!(
221                    "empty i18n translation: {locale}.{namespace}.{key}"
222                ))
223            })?;
224        if validate_placeholders {
225            validate_template(text).map_err(|message| {
226                RuntimeError::Language(format!("{locale}.{namespace}.{key}: {message}"))
227            })?;
228        }
229        parsed.insert(key.clone(), text.to_owned());
230    }
231    Ok(parsed)
232}
233
234fn validate_template(template: &str) -> Result<(), String> {
235    let mut characters = template.chars().peekable();
236    while let Some(character) = characters.next() {
237        match character {
238            '{' => {
239                let mut name = String::new();
240                loop {
241                    match characters.next() {
242                        Some('}') => break,
243                        Some('{') => return Err("nested template opening brace".to_owned()),
244                        Some(character) => name.push(character),
245                        None => return Err("unclosed template placeholder".to_owned()),
246                    }
247                }
248                if !ALLOWED_ARGUMENTS.contains(&name.as_str()) {
249                    return Err(format!("unknown template placeholder: {name}"));
250                }
251            }
252            '}' => return Err("unmatched template closing brace".to_owned()),
253            _ => {}
254        }
255    }
256    Ok(())
257}
258
259fn render_template(template: &str, arguments: &[(&str, &str)]) -> String {
260    arguments
261        .iter()
262        .fold(template.to_owned(), |rendered, (key, value)| {
263            rendered.replace(&format!("{{{key}}}"), value)
264        })
265}
266
267fn translate_location(location: &ObjectLocation) -> String {
268    title_case_path(&location.to_string())
269}
270
271fn title_case_path(path: &str) -> String {
272    path.split('.')
273        .map(|part| {
274            part.split_once('[')
275                .map(|(name, index)| format!("{}[{}", title_case_identifier(name), index))
276                .unwrap_or_else(|| title_case_identifier(part))
277        })
278        .collect::<Vec<_>>()
279        .join(".")
280}
281
282fn title_case_identifier(value: &str) -> String {
283    let mut output = String::new();
284    for (index, ch) in value.chars().enumerate() {
285        if index > 0 && ch.is_uppercase() {
286            output.push(' ');
287        }
288        match index {
289            0 => output.extend(ch.to_uppercase()),
290            _ => output.extend(ch.to_lowercase()),
291        }
292    }
293    output
294}
295
296fn format_value(value: &Value) -> String {
297    match value {
298        Value::Null => "null".to_owned(),
299        Value::Bool(value) => value.to_string(),
300        Value::I64(value) => value.to_string(),
301        Value::U64(value) => value.to_string(),
302        Value::F64(value) => value.to_string(),
303        Value::Decimal(value) => value.to_string(),
304        Value::Text(value) => value.clone(),
305        Value::Json(value) => value.to_string(),
306        Value::Date(value) => value.to_string(),
307        Value::Timestamp(value) => value.0.to_string(),
308        Value::Object(_) => "<object>".to_owned(),
309        Value::List(_) => "<list>".to_owned(),
310        Value::TypedNull(_) => "null".to_owned(),
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn rejects_unknown_placeholders_before_installation() {
320        let invalid = BUILTIN_CATALOG_JSON.replace("{location}", "{unsafe}");
321        let error = I18nCatalog::from_json(&invalid).unwrap_err();
322        assert!(error.to_string().contains("unknown template placeholder"));
323    }
324
325    #[test]
326    fn falls_back_to_english_and_then_stable_key() {
327        let catalog = I18nCatalog::from_json(
328            r#"{
329                "schema":"teaql.i18n/v1",
330                "defaultLocale":"en",
331                "locales":{"en":{"messages":{"known":"English"},"vocabulary":{}}}
332            }"#,
333        )
334        .unwrap();
335        assert_eq!(catalog.message(Language::French, "known"), "English");
336        assert_eq!(catalog.message(Language::French, "missing"), "missing");
337        assert_eq!(
338            catalog.message(Language::French, "checker.required"),
339            I18nCatalog::builtin().message(Language::French, "checker.required")
340        );
341    }
342}