Skip to main content

laterite_core/
i18n.rs

1//! UI-string localization.
2//!
3//! String-key and deferred: a [`Text`] holds an English source (the lookup key),
4//! optional context, plural, and arguments, but no locale. A per-request
5//! [`Translator`] localizes it to a `String` at the sink, so a message carries no
6//! ambient locale and can cross a redirect or be composed in core.
7//!
8//! [`Text`] has no `Display`: a bare user-facing string cannot render directly, so
9//! the type enforces that every string goes through the translator. Translating
10//! stored content is separate (the `translatable` field flag), not this.
11
12use std::borrow::Cow;
13use std::collections::HashMap;
14use std::fmt;
15use std::sync::Arc;
16
17use serde::de::{self, MapAccess, Visitor};
18use serde::ser::{
19    SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple,
20    SerializeTupleStruct, SerializeTupleVariant,
21};
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24/// The QA pseudo-locale: selecting it renders every localized string accented and
25/// bracketed, so any unwrapped (non-localized) UI string stands out and clipped
26/// layouts show up under the wider text. It needs no catalog.
27pub const PSEUDO_LOCALE: &str = "xx";
28
29/// A CLDR plural category. The built-in rules use only `One`/`Other`; the enum is
30/// `non_exhaustive` so the full CLDR set (`Zero`/`Two`/`Few`/`Many`) can arrive with
31/// an `icu_plurals` feature without breaking match arms.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum PluralCategory {
35    One,
36    Other,
37}
38
39/// The CLDR plural category for `n` in `locale`. A small built-in set of rules; the
40/// full per-language CLDR set arrives behind an `icu_plurals` feature. Values are
41/// integers, so CLDR `v` is always 0.
42pub fn plural_category(locale: &str, n: i64) -> PluralCategory {
43    let lang = locale.split(['-', '_']).next().unwrap_or(locale);
44    let n = n.unsigned_abs();
45    let one = match lang {
46        // A partial set pending icu_plurals: languages where 0 and 1 are singular
47        // (CLDR `i = 0 or n = 1`), such as hi and kn.
48        "hi" | "kn" => n == 0 || n == 1,
49        // English and the default: singular only at 1.
50        _ => n == 1,
51    };
52    if one {
53        PluralCategory::One
54    } else {
55        PluralCategory::Other
56    }
57}
58
59/// A substitution argument for a `{name}` placeholder in a [`Text`].
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub enum Arg {
62    Str(String),
63    Int(i64),
64    Num(f64),
65    /// A translatable argument, localized recursively at the sink.
66    Text(Box<Text>),
67}
68
69impl From<String> for Arg {
70    fn from(s: String) -> Self {
71        Arg::Str(s)
72    }
73}
74impl From<&str> for Arg {
75    fn from(s: &str) -> Self {
76        Arg::Str(s.to_string())
77    }
78}
79impl From<i64> for Arg {
80    fn from(n: i64) -> Self {
81        Arg::Int(n)
82    }
83}
84impl From<f64> for Arg {
85    fn from(n: f64) -> Self {
86        Arg::Num(n)
87    }
88}
89impl From<Text> for Arg {
90    fn from(t: Text) -> Self {
91        Arg::Text(Box::new(t))
92    }
93}
94
95// A descriptor label is authored as a plain string and localized at render: these
96// let a builder (`FormField::text("name", "Name")`) and a `.into()` on a descriptor
97// literal produce a `Text` without ceremony. The message sinks still take `Text` by
98// value, so a bare literal there is a compile error, not a silent conversion.
99impl From<&str> for Text {
100    fn from(s: &str) -> Self {
101        Text::dynamic(s)
102    }
103}
104impl From<String> for Text {
105    fn from(s: String) -> Self {
106        Text::dynamic(s)
107    }
108}
109
110/// A locale-free, translatable message; the English source is the lookup key. Build
111/// with the `t!`/`tn!`/`tp!` macros or the builders here. Serializes as a plain
112/// string when it has no context/plural/args (a descriptor label stays plain on
113/// disk), else as a full object (a flash message keeps its args across a round-trip).
114/// No `Display`.
115#[derive(Debug, Clone, PartialEq)]
116pub struct Text {
117    source: Cow<'static, str>,
118    context: Option<Cow<'static, str>>,
119    plural: Option<(Cow<'static, str>, i64)>,
120    args: Vec<(Cow<'static, str>, Arg)>,
121}
122
123impl Text {
124    /// A message from a static source string (the macro path).
125    pub fn new(source: impl Into<Cow<'static, str>>) -> Self {
126        Self {
127            source: source.into(),
128            context: None,
129            plural: None,
130            args: Vec::new(),
131        }
132    }
133
134    /// A message from a runtime (non-literal) string: the escape hatch extraction
135    /// ignores.
136    pub fn dynamic(s: impl Into<String>) -> Self {
137        Self::new(Cow::Owned(s.into()))
138    }
139
140    /// Disambiguates a homonym source (`Open` the verb vs the adjective). The
141    /// context is part of the lookup key, never shown.
142    pub fn with_context(mut self, context: impl Into<Cow<'static, str>>) -> Self {
143        self.context = Some(context.into());
144        self
145    }
146
147    /// Marks a plural message: `other` is the plural source form, `n` the count the
148    /// category is chosen from.
149    pub fn with_plural(mut self, other: impl Into<Cow<'static, str>>, n: i64) -> Self {
150        self.plural = Some((other.into(), n));
151        self
152    }
153
154    /// Binds a `{name}` placeholder value.
155    pub fn arg(mut self, name: impl Into<Cow<'static, str>>, value: impl Into<Arg>) -> Self {
156        self.args.push((name.into(), value.into()));
157        self
158    }
159
160    /// The source (English) string, the lookup key.
161    pub fn source(&self) -> &str {
162        &self.source
163    }
164
165    /// Whether this is a bare source with no context, plural, or arguments (the form
166    /// that serializes as a plain string).
167    fn is_plain(&self) -> bool {
168        self.context.is_none() && self.plural.is_none() && self.args.is_empty()
169    }
170}
171
172impl Serialize for Text {
173    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
174        if self.is_plain() {
175            // A plain string on disk, tagged so a collecting serializer can spot it.
176            ser.serialize_newtype_struct("laterite.Text", &*self.source)
177        } else {
178            let mut st = ser.serialize_struct("laterite.Text", 4)?;
179            st.serialize_field("source", &*self.source)?;
180            st.serialize_field("context", &self.context)?;
181            st.serialize_field("plural", &self.plural)?;
182            st.serialize_field("args", &self.args)?;
183            st.end()
184        }
185    }
186}
187
188impl<'de> Deserialize<'de> for Text {
189    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
190        struct TextVisitor;
191
192        impl<'de> Visitor<'de> for TextVisitor {
193            type Value = Text;
194
195            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
196                f.write_str("a source string or a Text object")
197            }
198
199            fn visit_str<E: de::Error>(self, v: &str) -> Result<Text, E> {
200                Ok(Text::dynamic(v))
201            }
202
203            fn visit_string<E: de::Error>(self, v: String) -> Result<Text, E> {
204                Ok(Text::dynamic(v))
205            }
206
207            fn visit_newtype_struct<D: Deserializer<'de>>(self, de: D) -> Result<Text, D::Error> {
208                Ok(Text::dynamic(String::deserialize(de)?))
209            }
210
211            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Text, A::Error> {
212                let mut source: Option<String> = None;
213                let mut context: Option<String> = None;
214                let mut plural: Option<(String, i64)> = None;
215                let mut args: Vec<(String, Arg)> = Vec::new();
216                while let Some(key) = map.next_key::<String>()? {
217                    match key.as_str() {
218                        "source" => source = Some(map.next_value()?),
219                        "context" => context = map.next_value()?,
220                        "plural" => plural = map.next_value()?,
221                        "args" => args = map.next_value()?,
222                        _ => {
223                            let _: de::IgnoredAny = map.next_value()?;
224                        }
225                    }
226                }
227                let source = source.ok_or_else(|| de::Error::missing_field("source"))?;
228                Ok(Text {
229                    source: Cow::Owned(source),
230                    context: context.map(Cow::Owned),
231                    plural: plural.map(|(s, n)| (Cow::Owned(s), n)),
232                    args: args.into_iter().map(|(k, v)| (Cow::Owned(k), v)).collect(),
233                })
234            }
235        }
236
237        de.deserialize_any(TextVisitor)
238    }
239}
240
241/// A stored translation for one key in one locale.
242#[derive(Debug, Clone)]
243enum Entry {
244    /// A single form.
245    One(String),
246    /// Plural forms by category (only `one`/`other` for the built-in rules).
247    Plural { one: String, other: String },
248}
249
250/// Per-locale message catalogs, keyed by source (and context), built from module
251/// contributions. File loading arrives with the i18n tooling.
252#[derive(Debug, Default)]
253pub struct CatalogStore {
254    // locale -> (key -> entry). The key is the source, or "context\u{4}source".
255    locales: HashMap<String, HashMap<String, Entry>>,
256}
257
258impl CatalogStore {
259    /// Starts an in-memory catalog; a file loader lands with the tooling.
260    pub fn builder() -> CatalogBuilder {
261        CatalogBuilder {
262            store: CatalogStore::default(),
263        }
264    }
265
266    fn lookup(&self, locale: &str, key: &str) -> Option<&Entry> {
267        self.locales.get(locale).and_then(|m| m.get(key))
268    }
269
270    /// The locales this store holds a catalog for, sorted. English is the source
271    /// and needs no catalog, so it is never listed; callers add it themselves.
272    pub fn locales(&self) -> Vec<String> {
273        let mut out: Vec<String> = self.locales.keys().cloned().collect();
274        out.sort();
275        out
276    }
277}
278
279/// The composite lookup key: the source, prefixed by the context when present.
280fn catalog_key(source: &str, context: Option<&str>) -> String {
281    match context {
282        Some(c) => format!("{c}\u{4}{source}"),
283        None => source.to_string(),
284    }
285}
286
287/// Builds a [`CatalogStore`] from message contributions.
288pub struct CatalogBuilder {
289    store: CatalogStore,
290}
291
292impl CatalogBuilder {
293    /// Adds a single-form translation for a locale.
294    pub fn message(mut self, locale: &str, source: &str, translation: &str) -> Self {
295        self.insert(
296            locale,
297            catalog_key(source, None),
298            Entry::One(translation.to_string()),
299        );
300        self
301    }
302
303    /// Adds a context-disambiguated translation for a locale.
304    pub fn message_ctx(
305        mut self,
306        locale: &str,
307        context: &str,
308        source: &str,
309        translation: &str,
310    ) -> Self {
311        self.insert(
312            locale,
313            catalog_key(source, Some(context)),
314            Entry::One(translation.to_string()),
315        );
316        self
317    }
318
319    /// Adds plural forms for a locale.
320    pub fn plural(mut self, locale: &str, source: &str, one: &str, other: &str) -> Self {
321        self.insert(
322            locale,
323            catalog_key(source, None),
324            Entry::Plural {
325                one: one.to_string(),
326                other: other.to_string(),
327            },
328        );
329        self
330    }
331
332    /// Parses a gettext PO catalog and merges its entries into `locale`. The header
333    /// (empty `msgid`) and untranslated (empty `msgstr`) entries are skipped. Errors
334    /// on malformed PO or a translation whose `{placeholders}` are not a subset of its
335    /// source's, so a bad catalog fails boot like a bad descriptor key. Launch locales
336    /// use `msgstr[0]`/`msgstr[1]` for the one/other plural forms.
337    pub fn po(mut self, locale: &str, text: &str) -> Result<Self, String> {
338        for entry in parse_po_entries(text)? {
339            if entry.id.is_empty() {
340                continue; // the PO header
341            }
342            let mut source = placeholder_set(&entry.id);
343            if let Some(p) = &entry.id_plural {
344                source.extend(placeholder_set(p));
345            }
346            let stored = if entry.id_plural.is_some() {
347                let one = entry.plural_forms.first().cloned().unwrap_or_default();
348                let other = entry.plural_forms.get(1).cloned().unwrap_or_default();
349                if one.is_empty() && other.is_empty() {
350                    continue; // untranslated plural
351                }
352                if one.is_empty() || other.is_empty() {
353                    return Err(format!(
354                        "`{}`: a plural needs msgstr[0] and msgstr[1]",
355                        entry.id
356                    ));
357                }
358                validate_placeholders(&one, &source, &entry.id)?;
359                validate_placeholders(&other, &source, &entry.id)?;
360                Entry::Plural { one, other }
361            } else {
362                match entry.singular {
363                    Some(s) if !s.is_empty() => {
364                        validate_placeholders(&s, &source, &entry.id)?;
365                        Entry::One(s)
366                    }
367                    _ => continue, // untranslated
368                }
369            };
370            self.insert(
371                locale,
372                catalog_key(&entry.id, entry.context.as_deref()),
373                stored,
374            );
375        }
376        Ok(self)
377    }
378
379    fn insert(&mut self, locale: &str, key: String, entry: Entry) {
380        self.store
381            .locales
382            .entry(locale.to_string())
383            .or_default()
384            .insert(key, entry);
385    }
386
387    pub fn build(self) -> CatalogStore {
388        self.store
389    }
390}
391
392/// The `{name}` placeholders in `s` (ignoring `{{`/`}}`), for validating a
393/// translation against its source form.
394fn placeholder_set(s: &str) -> std::collections::BTreeSet<String> {
395    let mut out = std::collections::BTreeSet::new();
396    let mut chars = s.chars().peekable();
397    while let Some(c) = chars.next() {
398        match c {
399            '{' if chars.peek() == Some(&'{') => {
400                chars.next();
401            }
402            '{' => {
403                let mut name = String::new();
404                for ch in chars.by_ref() {
405                    if ch == '}' {
406                        break;
407                    }
408                    name.push(ch);
409                }
410                if !name.is_empty() {
411                    out.insert(name);
412                }
413            }
414            '}' if chars.peek() == Some(&'}') => {
415                chars.next();
416            }
417            _ => {}
418        }
419    }
420    out
421}
422
423/// Errors if `translation` uses a `{placeholder}` its `source` form does not have.
424fn validate_placeholders(
425    translation: &str,
426    source: &std::collections::BTreeSet<String>,
427    id: &str,
428) -> Result<(), String> {
429    for p in placeholder_set(translation) {
430        if !source.contains(&p) {
431            return Err(format!(
432                "`{id}`: translation uses unknown placeholder {{{p}}}"
433            ));
434        }
435    }
436    Ok(())
437}
438
439/// One parsed PO entry, before it becomes a catalog [`Entry`].
440#[derive(Default)]
441struct PoEntry {
442    context: Option<String>,
443    id: String,
444    id_plural: Option<String>,
445    singular: Option<String>,
446    plural_forms: Vec<String>,
447}
448
449/// The field a bare continuation string (`"..."`) appends to.
450enum PoField {
451    None,
452    Context,
453    Id,
454    IdPlural,
455    Singular,
456    Plural(usize),
457}
458
459/// Parses PO text into entries: a minimal gettext subset (`msgctxt`/`msgid`/
460/// `msgid_plural`/`msgstr`/`msgstr[n]`, adjacent-string continuation, `#` comments,
461/// blank-line separators). Enough to load a derived catalog, not to author one.
462fn parse_po_entries(text: &str) -> Result<Vec<PoEntry>, String> {
463    let mut entries = Vec::new();
464    let mut cur = PoEntry::default();
465    let mut have = false;
466    let mut field = PoField::None;
467    for (i, raw) in text.lines().enumerate() {
468        let line = raw.trim();
469        let at = |m: &str| format!("PO line {}: {m}", i + 1);
470        if line.is_empty() {
471            if have {
472                entries.push(std::mem::take(&mut cur));
473                have = false;
474                field = PoField::None;
475            }
476        } else if line.starts_with('#') {
477            continue;
478        } else if let Some(rest) = line.strip_prefix("msgctxt ") {
479            cur.context = Some(unquote(rest).map_err(|e| at(&e))?);
480            have = true;
481            field = PoField::Context;
482        } else if let Some(rest) = line.strip_prefix("msgid_plural ") {
483            cur.id_plural = Some(unquote(rest).map_err(|e| at(&e))?);
484            have = true;
485            field = PoField::IdPlural;
486        } else if let Some(rest) = line.strip_prefix("msgid ") {
487            if have && !cur.id.is_empty() {
488                entries.push(std::mem::take(&mut cur));
489            }
490            cur.id = unquote(rest).map_err(|e| at(&e))?;
491            have = true;
492            field = PoField::Id;
493        } else if let Some(rest) = line.strip_prefix("msgstr[") {
494            let (idx, q) = rest
495                .split_once(']')
496                .ok_or_else(|| at("malformed msgstr[n]"))?;
497            let n: usize = idx.trim().parse().map_err(|_| at("bad plural index"))?;
498            let s = unquote(q.trim()).map_err(|e| at(&e))?;
499            if cur.plural_forms.len() <= n {
500                cur.plural_forms.resize(n + 1, String::new());
501            }
502            cur.plural_forms[n] = s;
503            have = true;
504            field = PoField::Plural(n);
505        } else if let Some(rest) = line.strip_prefix("msgstr ") {
506            cur.singular = Some(unquote(rest).map_err(|e| at(&e))?);
507            have = true;
508            field = PoField::Singular;
509        } else if line.starts_with('"') {
510            let s = unquote(line).map_err(|e| at(&e))?;
511            match field {
512                PoField::Context => cur.context.get_or_insert_with(String::new).push_str(&s),
513                PoField::Id => cur.id.push_str(&s),
514                PoField::IdPlural => cur.id_plural.get_or_insert_with(String::new).push_str(&s),
515                PoField::Singular => cur.singular.get_or_insert_with(String::new).push_str(&s),
516                PoField::Plural(n) => cur.plural_forms[n].push_str(&s),
517                PoField::None => return Err(at("string continuation with no field")),
518            }
519        } else {
520            return Err(at("unrecognized PO line"));
521        }
522    }
523    if have {
524        entries.push(cur);
525    }
526    Ok(entries)
527}
528
529/// Extracts and unescapes a PO quoted string (`"..."`), handling `\n \t \r \" \\`.
530fn unquote(s: &str) -> Result<String, String> {
531    let inner = s
532        .trim()
533        .strip_prefix('"')
534        .and_then(|s| s.strip_suffix('"'))
535        .ok_or_else(|| "expected a quoted string".to_string())?;
536    let mut out = String::with_capacity(inner.len());
537    let mut chars = inner.chars();
538    while let Some(c) = chars.next() {
539        if c == '\\' {
540            match chars.next() {
541                Some('n') => out.push('\n'),
542                Some('t') => out.push('\t'),
543                Some('r') => out.push('\r'),
544                Some('"') => out.push('"'),
545                Some('\\') => out.push('\\'),
546                Some(other) => {
547                    out.push('\\');
548                    out.push(other);
549                }
550                None => return Err("dangling escape in a PO string".to_string()),
551            }
552        } else {
553            out.push(c);
554        }
555    }
556    Ok(out)
557}
558
559/// Resolves [`Text`] for a request against a shared [`CatalogStore`] over a fallback
560/// chain (most specific first). Cheap to clone.
561#[derive(Debug, Clone)]
562pub struct Translator {
563    chain: Arc<[String]>,
564    store: Arc<CatalogStore>,
565}
566
567impl Translator {
568    /// A translator for one locale with no catalogs: every lookup falls back to the
569    /// source. The boot-time fallback translator.
570    pub fn new(locale: impl Into<String>) -> Self {
571        Self {
572            chain: Arc::from(vec![locale.into()]),
573            store: Arc::new(CatalogStore::default()),
574        }
575    }
576
577    /// A translator over an explicit fallback chain and a shared store.
578    pub fn with_chain(chain: Vec<String>, store: Arc<CatalogStore>) -> Self {
579        Self {
580            chain: Arc::from(chain),
581            store,
582        }
583    }
584
585    /// The active (most specific) locale.
586    pub fn locale(&self) -> &str {
587        self.chain.first().map(String::as_str).unwrap_or("en")
588    }
589
590    /// Localizes `text`: resolves the form along the chain (else the source), then
591    /// interpolates its `{name}` arguments. Under the pseudo-locale the resolved
592    /// template is accented and bracketed first.
593    pub fn t(&self, text: &Text) -> String {
594        let key = catalog_key(&text.source, text.context.as_deref());
595        let template = self.resolve(&key, text);
596        let template = if self.locale() == PSEUDO_LOCALE {
597            pseudo(&template)
598        } else {
599            template
600        };
601        interpolate(&template, &text.args, self)
602    }
603
604    /// The chosen form string: the first chain locale with an entry (picking the
605    /// plural form by that locale's rules), or the English source form.
606    fn resolve(&self, key: &str, text: &Text) -> String {
607        for locale in self.chain.iter() {
608            if let Some(entry) = self.store.lookup(locale, key) {
609                return match entry {
610                    Entry::One(s) => s.clone(),
611                    Entry::Plural { one, other } => {
612                        let n = text.plural.as_ref().map(|(_, n)| *n).unwrap_or(1);
613                        match plural_category(locale, n) {
614                            PluralCategory::One => one.clone(),
615                            _ => other.clone(),
616                        }
617                    }
618                };
619            }
620        }
621        // No catalog entry: the English source forms.
622        match &text.plural {
623            Some((other, n)) => match plural_category("en", *n) {
624                PluralCategory::One => text.source.to_string(),
625                _ => other.to_string(),
626            },
627            None => text.source.to_string(),
628        }
629    }
630}
631
632/// Replaces each `{name}` in `template` with its argument's localized value. A
633/// translatable argument is localized recursively through `tr`.
634fn interpolate(template: &str, args: &[(Cow<'static, str>, Arg)], tr: &Translator) -> String {
635    if args.is_empty() {
636        return template.to_string();
637    }
638    let mut out = template.to_string();
639    for (name, arg) in args {
640        let value = match arg {
641            Arg::Str(s) => s.clone(),
642            Arg::Int(n) => n.to_string(),
643            Arg::Num(n) => n.to_string(),
644            Arg::Text(t) => tr.t(t),
645        };
646        out = out.replace(&format!("{{{name}}}"), &value);
647    }
648    out
649}
650
651/// Pseudo-localizes a resolved template for [`PSEUDO_LOCALE`]: accents letters and
652/// wraps the whole in brackets. `{name}` placeholders (and `{{`/`}}` literals) are
653/// copied verbatim so interpolation still matches and the args stay real data.
654fn pseudo(template: &str) -> String {
655    let mut out = String::with_capacity(template.len() + 6);
656    out.push('\u{27E6}'); // (left white square bracket)
657    let mut chars = template.chars().peekable();
658    while let Some(c) = chars.next() {
659        match c {
660            '{' if chars.peek() == Some(&'{') => {
661                out.push(c);
662                out.push(chars.next().unwrap());
663            }
664            '{' => {
665                out.push(c);
666                for ch in chars.by_ref() {
667                    out.push(ch);
668                    if ch == '}' {
669                        break;
670                    }
671                }
672            }
673            _ => out.push(accent(c)),
674        }
675    }
676    out.push('\u{27E7}'); // (right white square bracket)
677    out
678}
679
680/// Maps a vowel to an accented form for the pseudo-locale; other characters pass
681/// through. Vowels alone make the text visibly non-English while staying readable.
682fn accent(c: char) -> char {
683    match c {
684        'a' => '\u{e4}',
685        'e' => '\u{e9}',
686        'i' => '\u{ed}',
687        'o' => '\u{f6}',
688        'u' => '\u{fc}',
689        'A' => '\u{c4}',
690        'E' => '\u{c9}',
691        'I' => '\u{cd}',
692        'O' => '\u{d6}',
693        'U' => '\u{dc}',
694        other => other,
695    }
696}
697
698/// The newtype name a plain [`Text`] serializes under, so a collector can spot it.
699const TEXT_MARKER: &str = "laterite.Text";
700
701/// Harvests the source string of every plain [`Text`] reachable in `value`, by
702/// intercepting the `laterite.Text` newtype marker its `Serialize` emits. This lets
703/// `lat i18n extract` collect the label strings that live in descriptor *data*
704/// (nav/title/column/permission/settings labels) without the collector knowing the
705/// descriptor shapes, so it cannot drift as new label fields are added. Descriptor
706/// labels are plain `Text`; a rich `Text` (context/plural/args) does not appear in a
707/// descriptor and is not collected here.
708pub fn collect_sources<T: Serialize>(value: &T) -> Vec<String> {
709    let mut out = Vec::new();
710    let _ = value.serialize(SourceCollector {
711        out: &mut out,
712        capture: false,
713    });
714    out
715}
716
717/// The (unreachable) error type for the infallible collector serializer.
718#[derive(Debug)]
719pub struct CollectError;
720impl fmt::Display for CollectError {
721    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
722        f.write_str("source collector error")
723    }
724}
725impl std::error::Error for CollectError {}
726impl serde::ser::Error for CollectError {
727    fn custom<T: fmt::Display>(_: T) -> Self {
728        CollectError
729    }
730}
731
732/// A serializer that walks a value and records `Text` sources. `capture` is set only
733/// for the string inside a `laterite.Text` newtype, so a plain data string elsewhere
734/// (an entity name, a path) is walked but never recorded.
735struct SourceCollector<'a> {
736    out: &'a mut Vec<String>,
737    capture: bool,
738}
739
740impl<'a> SourceCollector<'a> {
741    fn child(&mut self) -> SourceCollector<'_> {
742        SourceCollector {
743            out: self.out,
744            capture: false,
745        }
746    }
747}
748
749impl<'a> Serializer for SourceCollector<'a> {
750    type Ok = ();
751    type Error = CollectError;
752    type SerializeSeq = Compound<'a>;
753    type SerializeTuple = Compound<'a>;
754    type SerializeTupleStruct = Compound<'a>;
755    type SerializeTupleVariant = Compound<'a>;
756    type SerializeMap = Compound<'a>;
757    type SerializeStruct = Compound<'a>;
758    type SerializeStructVariant = Compound<'a>;
759
760    fn serialize_str(self, v: &str) -> Result<(), CollectError> {
761        if self.capture {
762            self.out.push(v.to_string());
763        }
764        Ok(())
765    }
766
767    fn serialize_newtype_struct<T: ?Sized + Serialize>(
768        self,
769        name: &'static str,
770        value: &T,
771    ) -> Result<(), CollectError> {
772        let collector = SourceCollector {
773            out: self.out,
774            capture: name == TEXT_MARKER,
775        };
776        value.serialize(collector)
777    }
778
779    fn serialize_some<T: ?Sized + Serialize>(mut self, value: &T) -> Result<(), CollectError> {
780        value.serialize(self.child())
781    }
782
783    fn serialize_newtype_variant<T: ?Sized + Serialize>(
784        mut self,
785        _n: &'static str,
786        _i: u32,
787        _v: &'static str,
788        value: &T,
789    ) -> Result<(), CollectError> {
790        value.serialize(self.child())
791    }
792
793    fn serialize_seq(self, _len: Option<usize>) -> Result<Compound<'a>, CollectError> {
794        Ok(Compound { out: self.out })
795    }
796    fn serialize_tuple(self, _len: usize) -> Result<Compound<'a>, CollectError> {
797        Ok(Compound { out: self.out })
798    }
799    fn serialize_tuple_struct(
800        self,
801        _n: &'static str,
802        _len: usize,
803    ) -> Result<Compound<'a>, CollectError> {
804        Ok(Compound { out: self.out })
805    }
806    fn serialize_tuple_variant(
807        self,
808        _n: &'static str,
809        _i: u32,
810        _v: &'static str,
811        _len: usize,
812    ) -> Result<Compound<'a>, CollectError> {
813        Ok(Compound { out: self.out })
814    }
815    fn serialize_map(self, _len: Option<usize>) -> Result<Compound<'a>, CollectError> {
816        Ok(Compound { out: self.out })
817    }
818    fn serialize_struct(self, _n: &'static str, _len: usize) -> Result<Compound<'a>, CollectError> {
819        Ok(Compound { out: self.out })
820    }
821    fn serialize_struct_variant(
822        self,
823        _n: &'static str,
824        _i: u32,
825        _v: &'static str,
826        _len: usize,
827    ) -> Result<Compound<'a>, CollectError> {
828        Ok(Compound { out: self.out })
829    }
830
831    // Scalars and unit-like values carry no `Text`; walk over them.
832    fn serialize_bool(self, _v: bool) -> Result<(), CollectError> {
833        Ok(())
834    }
835    fn serialize_i8(self, _v: i8) -> Result<(), CollectError> {
836        Ok(())
837    }
838    fn serialize_i16(self, _v: i16) -> Result<(), CollectError> {
839        Ok(())
840    }
841    fn serialize_i32(self, _v: i32) -> Result<(), CollectError> {
842        Ok(())
843    }
844    fn serialize_i64(self, _v: i64) -> Result<(), CollectError> {
845        Ok(())
846    }
847    fn serialize_u8(self, _v: u8) -> Result<(), CollectError> {
848        Ok(())
849    }
850    fn serialize_u16(self, _v: u16) -> Result<(), CollectError> {
851        Ok(())
852    }
853    fn serialize_u32(self, _v: u32) -> Result<(), CollectError> {
854        Ok(())
855    }
856    fn serialize_u64(self, _v: u64) -> Result<(), CollectError> {
857        Ok(())
858    }
859    fn serialize_f32(self, _v: f32) -> Result<(), CollectError> {
860        Ok(())
861    }
862    fn serialize_f64(self, _v: f64) -> Result<(), CollectError> {
863        Ok(())
864    }
865    fn serialize_char(self, _v: char) -> Result<(), CollectError> {
866        Ok(())
867    }
868    fn serialize_bytes(self, _v: &[u8]) -> Result<(), CollectError> {
869        Ok(())
870    }
871    fn serialize_none(self) -> Result<(), CollectError> {
872        Ok(())
873    }
874    fn serialize_unit(self) -> Result<(), CollectError> {
875        Ok(())
876    }
877    fn serialize_unit_struct(self, _n: &'static str) -> Result<(), CollectError> {
878        Ok(())
879    }
880    fn serialize_unit_variant(
881        self,
882        _n: &'static str,
883        _i: u32,
884        _v: &'static str,
885    ) -> Result<(), CollectError> {
886        Ok(())
887    }
888}
889
890/// The one compound-serializer type all of `Serializer`'s associated types share:
891/// it recurses each element/value with a fresh collector and ignores keys.
892struct Compound<'a> {
893    out: &'a mut Vec<String>,
894}
895
896impl Compound<'_> {
897    fn element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), CollectError> {
898        value.serialize(SourceCollector {
899            out: self.out,
900            capture: false,
901        })
902    }
903}
904
905impl SerializeSeq for Compound<'_> {
906    type Ok = ();
907    type Error = CollectError;
908    fn serialize_element<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), CollectError> {
909        self.element(v)
910    }
911    fn end(self) -> Result<(), CollectError> {
912        Ok(())
913    }
914}
915impl SerializeTuple for Compound<'_> {
916    type Ok = ();
917    type Error = CollectError;
918    fn serialize_element<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), CollectError> {
919        self.element(v)
920    }
921    fn end(self) -> Result<(), CollectError> {
922        Ok(())
923    }
924}
925impl SerializeTupleStruct for Compound<'_> {
926    type Ok = ();
927    type Error = CollectError;
928    fn serialize_field<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), CollectError> {
929        self.element(v)
930    }
931    fn end(self) -> Result<(), CollectError> {
932        Ok(())
933    }
934}
935impl SerializeTupleVariant for Compound<'_> {
936    type Ok = ();
937    type Error = CollectError;
938    fn serialize_field<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), CollectError> {
939        self.element(v)
940    }
941    fn end(self) -> Result<(), CollectError> {
942        Ok(())
943    }
944}
945impl SerializeMap for Compound<'_> {
946    type Ok = ();
947    type Error = CollectError;
948    fn serialize_key<T: ?Sized + Serialize>(&mut self, _k: &T) -> Result<(), CollectError> {
949        Ok(())
950    }
951    fn serialize_value<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), CollectError> {
952        self.element(v)
953    }
954    fn end(self) -> Result<(), CollectError> {
955        Ok(())
956    }
957}
958impl SerializeStruct for Compound<'_> {
959    type Ok = ();
960    type Error = CollectError;
961    fn serialize_field<T: ?Sized + Serialize>(
962        &mut self,
963        _k: &'static str,
964        v: &T,
965    ) -> Result<(), CollectError> {
966        self.element(v)
967    }
968    fn end(self) -> Result<(), CollectError> {
969        Ok(())
970    }
971}
972impl SerializeStructVariant for Compound<'_> {
973    type Ok = ();
974    type Error = CollectError;
975    fn serialize_field<T: ?Sized + Serialize>(
976        &mut self,
977        _k: &'static str,
978        v: &T,
979    ) -> Result<(), CollectError> {
980        self.element(v)
981    }
982    fn end(self) -> Result<(), CollectError> {
983        Ok(())
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990
991    fn store() -> Arc<CatalogStore> {
992        Arc::new(
993            CatalogStore::builder()
994                .message(
995                    "kn",
996                    "Dashboard",
997                    "\u{ca1}\u{ccd}\u{caf}\u{cbe}\u{cb6}\u{ccd}\u{cac}\u{ccb}\u{cb0}\u{ccd}\u{ca1}",
998                )
999                .message(
1000                    "kn",
1001                    "Welcome, {name}",
1002                    "\u{cb8}\u{ccd}\u{cb5}\u{cbe}\u{c97}\u{ca4}, {name}",
1003                )
1004                .message_ctx("kn", "verb", "Open", "\u{ca4}\u{cc6}\u{cb0}\u{cc6}")
1005                .plural(
1006                    "kn",
1007                    "{n} item",
1008                    "{n} \u{c90}\u{c9f}\u{c82}",
1009                    "{n} \u{c90}\u{c9f}\u{c82}\u{c97}\u{cb3}\u{cc1}",
1010                )
1011                .plural("en", "{n} item", "{n} item", "{n} items")
1012                .build(),
1013        )
1014    }
1015
1016    fn kn() -> Translator {
1017        Translator::with_chain(vec!["kn".into(), "en".into()], store())
1018    }
1019
1020    #[test]
1021    fn falls_back_to_source_with_no_catalog() {
1022        let t = Translator::new("en");
1023        assert_eq!(t.t(&Text::new("Settings")), "Settings");
1024    }
1025
1026    #[test]
1027    fn translates_and_interpolates() {
1028        let t = kn();
1029        assert_eq!(
1030            t.t(&Text::new("Welcome, {name}").arg("name", "Asha")),
1031            "\u{cb8}\u{ccd}\u{cb5}\u{cbe}\u{c97}\u{ca4}, Asha"
1032        );
1033        // A source the catalog does not cover falls back, still interpolating.
1034        assert_eq!(t.t(&Text::new("Hi {name}").arg("name", "Asha")), "Hi Asha");
1035    }
1036
1037    #[test]
1038    fn context_disambiguates() {
1039        let t = kn();
1040        // The `verb` context has an entry; the same source without context falls back.
1041        assert_eq!(
1042            t.t(&Text::new("Open").with_context("verb")),
1043            "\u{ca4}\u{cc6}\u{cb0}\u{cc6}"
1044        );
1045        assert_eq!(t.t(&Text::new("Open")), "Open");
1046    }
1047
1048    #[test]
1049    fn plural_picks_the_form_by_locale_rules() {
1050        let t = kn();
1051        let msg = |n: i64| {
1052            t.t(&Text::new("{n} item")
1053                .with_plural("{n} items", n)
1054                .arg("n", n))
1055        };
1056        // Kannada: 0 and 1 are singular, 2+ plural.
1057        assert_eq!(msg(0), "0 \u{c90}\u{c9f}\u{c82}");
1058        assert_eq!(msg(1), "1 \u{c90}\u{c9f}\u{c82}");
1059        assert_eq!(msg(5), "5 \u{c90}\u{c9f}\u{c82}\u{c97}\u{cb3}\u{cc1}");
1060    }
1061
1062    #[test]
1063    fn plural_source_fallback_uses_english_rules() {
1064        // No kn/en override for this source: English source forms, English rules.
1065        let t = kn();
1066        let msg = |n: i64| {
1067            t.t(&Text::new("{n} file")
1068                .with_plural("{n} files", n)
1069                .arg("n", n))
1070        };
1071        assert_eq!(msg(1), "1 file");
1072        assert_eq!(msg(0), "0 files"); // English: only 1 is singular.
1073        assert_eq!(msg(3), "3 files");
1074    }
1075
1076    #[test]
1077    fn nested_translatable_argument() {
1078        // An argument that is itself a Text is localized recursively.
1079        let t = kn();
1080        let inner = Text::new("Dashboard");
1081        let out = t.t(&Text::new("Go to {page}").arg("page", inner));
1082        assert_eq!(
1083            out,
1084            "Go to \u{ca1}\u{ccd}\u{caf}\u{cbe}\u{cb6}\u{ccd}\u{cac}\u{ccb}\u{cb0}\u{ccd}\u{ca1}"
1085        );
1086    }
1087
1088    #[test]
1089    fn collect_sources_finds_text_but_not_plain_strings() {
1090        // A struct mixing plain data strings with Text labels; only the Text sources
1091        // are collected, and nested/optional Text is found too.
1092        #[derive(Serialize)]
1093        struct Field {
1094            name: String,
1095            label: Text,
1096        }
1097        #[derive(Serialize)]
1098        struct Descriptor {
1099            entity: String,
1100            title: Text,
1101            fields: Vec<Field>,
1102            help: Option<Text>,
1103        }
1104        let d = Descriptor {
1105            entity: "widgets".into(),
1106            title: Text::dynamic("Widgets"),
1107            fields: vec![Field {
1108                name: "code".into(),
1109                label: Text::dynamic("Code"),
1110            }],
1111            help: Some(Text::dynamic("Pick one")),
1112        };
1113        let mut got = collect_sources(&d);
1114        got.sort();
1115        assert_eq!(got, vec!["Code", "Pick one", "Widgets"]);
1116    }
1117
1118    #[test]
1119    fn po_catalog_loads_messages_context_and_plurals() {
1120        let po = concat!(
1121            "# a comment\n",
1122            "msgid \"\"\n",
1123            "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
1124            "\n",
1125            "msgid \"Save\"\n",
1126            "msgstr \"Save-kn\"\n",
1127            "\n",
1128            "msgctxt \"verb\"\n",
1129            "msgid \"Open\"\n",
1130            "msgstr \"Open-verb-kn\"\n",
1131            "\n",
1132            "msgid \"{n} item\"\n",
1133            "msgid_plural \"{n} items\"\n",
1134            "msgstr[0] \"{n} one-kn\"\n",
1135            "msgstr[1] \"{n} other-kn\"\n",
1136        );
1137        let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
1138        let tr = Translator::with_chain(vec!["kn".into(), "en".into()], store);
1139        assert_eq!(tr.t(&Text::new("Save")), "Save-kn");
1140        assert_eq!(
1141            tr.t(&Text::new("Open").with_context("verb")),
1142            "Open-verb-kn"
1143        );
1144        // A different (or absent) context is a different key: it falls back.
1145        assert_eq!(tr.t(&Text::new("Open")), "Open");
1146        let items = |n: i64| {
1147            tr.t(&Text::new("{n} item")
1148                .with_plural("{n} items", n)
1149                .arg("n", n))
1150        };
1151        assert_eq!(items(1), "1 one-kn");
1152        assert_eq!(items(3), "3 other-kn");
1153    }
1154
1155    #[test]
1156    fn po_rejects_a_translation_with_an_unknown_placeholder() {
1157        let po = "msgid \"Hi {name}\"\nmsgstr \"Hi {nom}\"\n";
1158        assert!(CatalogStore::builder().po("kn", po).is_err());
1159    }
1160
1161    #[test]
1162    fn po_skips_header_and_untranslated_entries() {
1163        let po = "msgid \"\"\nmsgstr \"meta\"\n\nmsgid \"Later\"\nmsgstr \"\"\n";
1164        let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
1165        let tr = Translator::with_chain(vec!["kn".into(), "en".into()], store);
1166        // The header is not a key, and an empty translation leaves the source to fall back.
1167        assert_eq!(tr.t(&Text::new("Later")), "Later");
1168    }
1169
1170    #[test]
1171    fn po_handles_string_continuation_and_escapes() {
1172        let po = "msgid \"Long\"\nmsgstr \"\"\n\"line one\\n\"\n\"line two\"\n";
1173        let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
1174        let tr = Translator::with_chain(vec!["kn".into(), "en".into()], store);
1175        assert_eq!(tr.t(&Text::new("Long")), "line one\nline two");
1176    }
1177
1178    #[test]
1179    fn pseudo_locale_accents_wraps_and_preserves_placeholders() {
1180        let t = Translator::new(PSEUDO_LOCALE);
1181        // A plain source is bracketed and its vowels accented.
1182        assert_eq!(t.t(&Text::new("Save")), "\u{27E6}S\u{e4}v\u{e9}\u{27E7}");
1183        // The placeholder is copied verbatim, so the arg still interpolates and the
1184        // arg value (real data) is not accented.
1185        let out = t.t(&Text::new("Hi {name}").arg("name", "Asha"));
1186        assert_eq!(out, "\u{27E6}H\u{ed} Asha\u{27E7}");
1187    }
1188
1189    #[test]
1190    fn plain_text_serializes_as_a_bare_string() {
1191        // A descriptor label round-trips as a plain string.
1192        let json = serde_json::to_string(&Text::new("Name")).unwrap();
1193        assert_eq!(json, "\"Name\"");
1194        let back: Text = serde_json::from_str("\"Name\"").unwrap();
1195        assert_eq!(back, Text::dynamic("Name"));
1196    }
1197
1198    #[test]
1199    fn rich_text_round_trips_as_an_object() {
1200        // A flash message keeps its arguments across a (session) round-trip.
1201        let msg = Text::new("Role {name} created").arg("name", "editor");
1202        let json = serde_json::to_string(&msg).unwrap();
1203        let back: Text = serde_json::from_str(&json).unwrap();
1204        assert_eq!(back.source(), "Role {name} created");
1205        assert_eq!(Translator::new("en").t(&back), "Role editor created");
1206    }
1207}