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#[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}