1use 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
24pub const PSEUDO_LOCALE: &str = "xx";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum PluralCategory {
35 One,
36 Other,
37}
38
39pub 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 "hi" | "kn" => n == 0 || n == 1,
49 _ => n == 1,
51 };
52 if one {
53 PluralCategory::One
54 } else {
55 PluralCategory::Other
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub enum Arg {
62 Str(String),
63 Int(i64),
64 Num(f64),
65 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
95impl 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#[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 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 pub fn dynamic(s: impl Into<String>) -> Self {
137 Self::new(Cow::Owned(s.into()))
138 }
139
140 pub fn with_context(mut self, context: impl Into<Cow<'static, str>>) -> Self {
143 self.context = Some(context.into());
144 self
145 }
146
147 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 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 pub fn source(&self) -> &str {
162 &self.source
163 }
164
165 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 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#[derive(Debug, Clone)]
243enum Entry {
244 One(String),
246 Plural { one: String, other: String },
248}
249
250#[derive(Debug, Default)]
253pub struct CatalogStore {
254 locales: HashMap<String, HashMap<String, Entry>>,
256}
257
258impl CatalogStore {
259 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 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
279fn 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
287pub struct CatalogBuilder {
289 store: CatalogStore,
290}
291
292impl CatalogBuilder {
293 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 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 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 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; }
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; }
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, }
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
392fn 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
423fn 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#[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
449enum PoField {
451 None,
452 Context,
453 Id,
454 IdPlural,
455 Singular,
456 Plural(usize),
457}
458
459fn 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
529fn 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#[derive(Debug, Clone)]
562pub struct Translator {
563 chain: Arc<[String]>,
564 store: Arc<CatalogStore>,
565}
566
567impl Translator {
568 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 pub fn with_chain(chain: Vec<String>, store: Arc<CatalogStore>) -> Self {
579 Self {
580 chain: Arc::from(chain),
581 store,
582 }
583 }
584
585 pub fn locale(&self) -> &str {
587 self.chain.first().map(String::as_str).unwrap_or("en")
588 }
589
590 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 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 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
632fn 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
651fn pseudo(template: &str) -> String {
655 let mut out = String::with_capacity(template.len() + 6);
656 out.push('\u{27E6}'); 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}'); out
678}
679
680fn 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
698const TEXT_MARKER: &str = "laterite.Text";
700
701pub 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#[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
732struct 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 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
890struct 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 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 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 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 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"); assert_eq!(msg(3), "3 files");
1074 }
1075
1076 #[test]
1077 fn nested_translatable_argument() {
1078 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 #[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 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 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 assert_eq!(t.t(&Text::new("Save")), "\u{27E6}S\u{e4}v\u{e9}\u{27E7}");
1183 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 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 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}