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::Min => "checker.min",
140            CheckRule::Max => "checker.max",
141            CheckRule::MinStringLength => "checker.minLength",
142            CheckRule::MaxStringLength => "checker.maxLength",
143            CheckRule::ContextRootMissing => "checker.contextRootMissing",
144            CheckRule::ContextRootMismatch => "checker.contextRootMismatch",
145        };
146        let location = translate_location(&result.location);
147        let system = result
148            .system_value
149            .as_ref()
150            .map(format_value)
151            .unwrap_or_else(|| "-".to_owned());
152        let input = result
153            .input_value
154            .as_ref()
155            .map(format_value)
156            .unwrap_or_else(|| "-".to_owned());
157        let input_len = result
158            .input_value
159            .as_ref()
160            .and_then(|value| match value {
161                Value::Text(value) => Some(value.chars().count()),
162                _ => None,
163            })
164            .unwrap_or(0)
165            .to_string();
166        render_template(
167            &self.message(language, key),
168            &[
169                ("location", location.as_str()),
170                ("system", system.as_str()),
171                ("input", input.as_str()),
172                ("input_len", input_len.as_str()),
173            ],
174        )
175    }
176
177    fn lookup(&self, locale: &str, namespace: &str, key: &str) -> Option<String> {
178        self.lookup_exact(locale, namespace, key)
179            .or_else(|| {
180                self.fallback
181                    .as_ref()
182                    .and_then(|fallback| fallback.lookup_exact(locale, namespace, key))
183            })
184            .or_else(|| self.lookup_exact(&self.default_locale, namespace, key))
185            .or_else(|| {
186                self.fallback.as_ref().and_then(|fallback| {
187                    fallback.lookup_exact(&fallback.default_locale, namespace, key)
188                })
189            })
190    }
191
192    fn lookup_exact(&self, locale: &str, namespace: &str, key: &str) -> Option<String> {
193        let catalog = self.locales.get(locale)?;
194        match namespace {
195            "messages" => catalog.messages.get(key).cloned(),
196            "vocabulary" => catalog.vocabulary.get(key).cloned(),
197            _ => None,
198        }
199    }
200}
201
202fn parse_entries(
203    value: Option<&serde_json::Value>,
204    locale: &str,
205    namespace: &str,
206    validate_placeholders: bool,
207) -> Result<BTreeMap<String, String>, RuntimeError> {
208    let entries = value
209        .and_then(serde_json::Value::as_object)
210        .ok_or_else(|| {
211            RuntimeError::Language(format!("i18n {locale}.{namespace} must be an object"))
212        })?;
213    let mut parsed = BTreeMap::new();
214    for (key, value) in entries {
215        let text = value
216            .as_str()
217            .filter(|text| !text.is_empty())
218            .ok_or_else(|| {
219                RuntimeError::Language(format!(
220                    "empty i18n translation: {locale}.{namespace}.{key}"
221                ))
222            })?;
223        if validate_placeholders {
224            validate_template(text).map_err(|message| {
225                RuntimeError::Language(format!("{locale}.{namespace}.{key}: {message}"))
226            })?;
227        }
228        parsed.insert(key.clone(), text.to_owned());
229    }
230    Ok(parsed)
231}
232
233fn validate_template(template: &str) -> Result<(), String> {
234    let mut characters = template.chars().peekable();
235    while let Some(character) = characters.next() {
236        match character {
237            '{' => {
238                let mut name = String::new();
239                loop {
240                    match characters.next() {
241                        Some('}') => break,
242                        Some('{') => return Err("nested template opening brace".to_owned()),
243                        Some(character) => name.push(character),
244                        None => return Err("unclosed template placeholder".to_owned()),
245                    }
246                }
247                if !ALLOWED_ARGUMENTS.contains(&name.as_str()) {
248                    return Err(format!("unknown template placeholder: {name}"));
249                }
250            }
251            '}' => return Err("unmatched template closing brace".to_owned()),
252            _ => {}
253        }
254    }
255    Ok(())
256}
257
258fn render_template(template: &str, arguments: &[(&str, &str)]) -> String {
259    arguments
260        .iter()
261        .fold(template.to_owned(), |rendered, (key, value)| {
262            rendered.replace(&format!("{{{key}}}"), value)
263        })
264}
265
266fn translate_location(location: &ObjectLocation) -> String {
267    title_case_path(&location.to_string())
268}
269
270fn title_case_path(path: &str) -> String {
271    path.split('.')
272        .map(|part| {
273            part.split_once('[')
274                .map(|(name, index)| format!("{}[{}", title_case_identifier(name), index))
275                .unwrap_or_else(|| title_case_identifier(part))
276        })
277        .collect::<Vec<_>>()
278        .join(".")
279}
280
281fn title_case_identifier(value: &str) -> String {
282    let mut output = String::new();
283    for (index, ch) in value.chars().enumerate() {
284        if index > 0 && ch.is_uppercase() {
285            output.push(' ');
286        }
287        match index {
288            0 => output.extend(ch.to_uppercase()),
289            _ => output.extend(ch.to_lowercase()),
290        }
291    }
292    output
293}
294
295fn format_value(value: &Value) -> String {
296    match value {
297        Value::Null => "null".to_owned(),
298        Value::Bool(value) => value.to_string(),
299        Value::I64(value) => value.to_string(),
300        Value::U64(value) => value.to_string(),
301        Value::F64(value) => value.to_string(),
302        Value::Decimal(value) => value.to_string(),
303        Value::Text(value) => value.clone(),
304        Value::Json(value) => value.to_string(),
305        Value::Date(value) => value.to_string(),
306        Value::Timestamp(value) => value.0.to_string(),
307        Value::Object(_) => "<object>".to_owned(),
308        Value::List(_) => "<list>".to_owned(),
309        Value::TypedNull(_) => "null".to_owned(),
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn rejects_unknown_placeholders_before_installation() {
319        let invalid = BUILTIN_CATALOG_JSON.replace("{location}", "{unsafe}");
320        let error = I18nCatalog::from_json(&invalid).unwrap_err();
321        assert!(error.to_string().contains("unknown template placeholder"));
322    }
323
324    #[test]
325    fn falls_back_to_english_and_then_stable_key() {
326        let catalog = I18nCatalog::from_json(
327            r#"{
328                "schema":"teaql.i18n/v1",
329                "defaultLocale":"en",
330                "locales":{"en":{"messages":{"known":"English"},"vocabulary":{}}}
331            }"#,
332        )
333        .unwrap();
334        assert_eq!(catalog.message(Language::French, "known"), "English");
335        assert_eq!(catalog.message(Language::French, "missing"), "missing");
336        assert_eq!(
337            catalog.message(Language::French, "checker.required"),
338            I18nCatalog::builtin().message(Language::French, "checker.required")
339        );
340    }
341}