1use std::collections::BTreeMap;
4use std::fmt::{Debug, Formatter};
5use std::sync::Arc;
6
7use crate::catalog::{Catalog, CatalogTranslation};
8use crate::generate_message_id;
9use crate::icu::{parse_icu, IcuNode, IcuParseError, IcuParserOptions};
10use crate::plurals::{get_plural_categories, get_plural_index};
11
12pub trait FormatHost {
14 fn locale(&self) -> &str;
16
17 fn format_number(
19 &self,
20 _name: &str,
21 value: &MessageValue,
22 _style: Option<&str>,
23 _values: &MessageValues,
24 ) -> Option<String> {
25 Some(display_value(value))
26 }
27
28 fn format_date(
30 &self,
31 _name: &str,
32 value: &MessageValue,
33 _style: Option<&str>,
34 _values: &MessageValues,
35 ) -> Option<String> {
36 Some(display_value(value))
37 }
38
39 fn format_time(
41 &self,
42 _name: &str,
43 value: &MessageValue,
44 _style: Option<&str>,
45 _values: &MessageValues,
46 ) -> Option<String> {
47 Some(display_value(value))
48 }
49
50 fn format_list(
52 &self,
53 _name: &str,
54 value: &MessageValue,
55 _style: Option<&str>,
56 _values: &MessageValues,
57 ) -> Option<String> {
58 Some(display_value(value))
59 }
60
61 fn format_duration(
63 &self,
64 _name: &str,
65 value: &MessageValue,
66 _style: Option<&str>,
67 _values: &MessageValues,
68 ) -> Option<String> {
69 Some(display_value(value))
70 }
71
72 fn format_ago(
74 &self,
75 _name: &str,
76 value: &MessageValue,
77 _style: Option<&str>,
78 _values: &MessageValues,
79 ) -> Option<String> {
80 Some(display_value(value))
81 }
82
83 fn format_name(
85 &self,
86 _name: &str,
87 value: &MessageValue,
88 _style: Option<&str>,
89 _values: &MessageValues,
90 ) -> Option<String> {
91 Some(display_value(value))
92 }
93
94 fn render_tag(&self, name: &str, children: &str, values: &MessageValues) -> Option<String> {
96 match values.get(name) {
97 Some(MessageValue::Tag(handler)) => Some(handler.render(children)),
98 _ => None,
99 }
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct DefaultFormatHost {
106 locale: String,
107}
108
109impl DefaultFormatHost {
110 #[must_use]
112 pub fn new(locale: impl Into<String>) -> Self {
113 Self {
114 locale: locale.into(),
115 }
116 }
117}
118
119impl FormatHost for DefaultFormatHost {
120 fn locale(&self) -> &str {
121 &self.locale
122 }
123}
124
125pub trait TagHandler: Send + Sync {
127 fn render(&self, children: &str) -> String;
129}
130
131impl<F> TagHandler for F
132where
133 F: Fn(&str) -> String + Send + Sync,
134{
135 fn render(&self, children: &str) -> String {
136 self(children)
137 }
138}
139
140pub type MessageValues = BTreeMap<String, MessageValue>;
142
143pub enum MessageValue {
145 String(String),
147 Number(f64),
149 Bool(bool),
151 List(Vec<MessageValue>),
153 Tag(Arc<dyn TagHandler>),
155}
156
157impl Clone for MessageValue {
158 fn clone(&self) -> Self {
159 match self {
160 Self::String(value) => Self::String(value.clone()),
161 Self::Number(value) => Self::Number(*value),
162 Self::Bool(value) => Self::Bool(*value),
163 Self::List(values) => Self::List(values.clone()),
164 Self::Tag(handler) => Self::Tag(Arc::clone(handler)),
165 }
166 }
167}
168
169impl Debug for MessageValue {
170 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
171 match self {
172 Self::String(value) => formatter.debug_tuple("String").field(value).finish(),
173 Self::Number(value) => formatter.debug_tuple("Number").field(value).finish(),
174 Self::Bool(value) => formatter.debug_tuple("Bool").field(value).finish(),
175 Self::List(value) => formatter.debug_tuple("List").field(value).finish(),
176 Self::Tag(_) => formatter.write_str("Tag(<handler>)"),
177 }
178 }
179}
180
181impl From<&str> for MessageValue {
182 fn from(value: &str) -> Self {
183 Self::String(value.to_owned())
184 }
185}
186
187impl From<String> for MessageValue {
188 fn from(value: String) -> Self {
189 Self::String(value)
190 }
191}
192
193impl From<f64> for MessageValue {
194 fn from(value: f64) -> Self {
195 Self::Number(value)
196 }
197}
198
199impl From<i32> for MessageValue {
200 fn from(value: i32) -> Self {
201 Self::Number(f64::from(value))
202 }
203}
204
205impl From<usize> for MessageValue {
206 fn from(value: usize) -> Self {
207 Self::Number(value as f64)
208 }
209}
210
211impl From<bool> for MessageValue {
212 fn from(value: bool) -> Self {
213 Self::Bool(value)
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct CompileIcuOptions {
220 pub locale: String,
222 pub strict: bool,
224}
225
226impl CompileIcuOptions {
227 #[must_use]
229 pub fn new(locale: impl Into<String>) -> Self {
230 Self {
231 locale: locale.into(),
232 strict: true,
233 }
234 }
235}
236
237#[derive(Debug, Clone)]
239pub struct CompiledMessage {
240 kind: CompiledMessageKind,
241 locale: String,
242}
243
244#[derive(Debug, Clone)]
245enum CompiledMessageKind {
246 Parsed(Vec<IcuNode>),
247 GettextPlural {
248 variable: String,
249 forms: Vec<CompiledMessage>,
250 },
251 Fallback(String),
252}
253
254impl CompiledMessage {
255 #[must_use]
257 pub fn format(&self, values: &MessageValues) -> String {
258 let host = DefaultFormatHost::new(self.locale.clone());
259 self.format_with_host(values, &host)
260 }
261
262 #[must_use]
264 pub fn format_with_host<H: FormatHost>(&self, values: &MessageValues, host: &H) -> String {
265 match &self.kind {
266 CompiledMessageKind::Parsed(ast) => render_nodes(ast, values, host, None),
267 CompiledMessageKind::GettextPlural { variable, forms } => {
268 render_gettext_plural(forms, variable, values, host)
269 }
270 CompiledMessageKind::Fallback(message) => message.clone(),
271 }
272 }
273}
274
275pub fn compile_icu(
277 message: &str,
278 options: &CompileIcuOptions,
279) -> Result<CompiledMessage, IcuParseError> {
280 match parse_icu(message, IcuParserOptions::default()) {
281 Ok(ast) => Ok(CompiledMessage {
282 kind: CompiledMessageKind::Parsed(ast),
283 locale: options.locale.clone(),
284 }),
285 Err(error) if options.strict => Err(error),
286 Err(_) => Ok(CompiledMessage {
287 kind: CompiledMessageKind::Fallback(message.to_owned()),
288 locale: options.locale.clone(),
289 }),
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct CompileCatalogOptions {
296 pub locale: String,
298 pub use_message_id: bool,
300 pub strict: bool,
302}
303
304impl CompileCatalogOptions {
305 #[must_use]
307 pub fn new(locale: impl Into<String>) -> Self {
308 Self {
309 locale: locale.into(),
310 use_message_id: true,
311 strict: false,
312 }
313 }
314}
315
316#[derive(Debug, Clone)]
318pub struct CompiledCatalog {
319 messages: BTreeMap<String, CompiledMessage>,
320 pub locale: String,
322}
323
324impl CompiledCatalog {
325 #[must_use]
327 pub fn get(&self, key: &str) -> Option<&CompiledMessage> {
328 self.messages.get(key)
329 }
330
331 #[must_use]
333 pub fn format(&self, key: &str, values: &MessageValues) -> String {
334 let host = DefaultFormatHost::new(self.locale.clone());
335 self.format_with_host(key, values, &host)
336 }
337
338 #[must_use]
340 pub fn format_with_host<H: FormatHost>(
341 &self,
342 key: &str,
343 values: &MessageValues,
344 host: &H,
345 ) -> String {
346 self.messages.get(key).map_or_else(
347 || key.to_owned(),
348 |message| message.format_with_host(values, host),
349 )
350 }
351
352 #[must_use]
354 pub fn has(&self, key: &str) -> bool {
355 self.messages.contains_key(key)
356 }
357
358 #[must_use]
360 pub fn keys(&self) -> Vec<String> {
361 self.messages.keys().cloned().collect()
362 }
363
364 #[must_use]
366 pub fn size(&self) -> usize {
367 self.messages.len()
368 }
369}
370
371pub fn compile_catalog(
373 catalog: &Catalog,
374 options: &CompileCatalogOptions,
375) -> Result<CompiledCatalog, IcuParseError> {
376 let mut messages = BTreeMap::new();
377
378 for (msgid, entry) in catalog {
379 let Some(translation) = &entry.translation else {
380 continue;
381 };
382
383 let key = if options.use_message_id {
384 generate_message_id(msgid, entry.context.as_deref())
385 } else {
386 msgid.clone()
387 };
388
389 let compiled = match translation {
390 CatalogTranslation::Singular(text) => compile_icu(
391 text,
392 &CompileIcuOptions {
393 locale: options.locale.clone(),
394 strict: options.strict,
395 },
396 )?,
397 CatalogTranslation::Plural(translations) => compile_gettext_plural_runtime(
398 msgid,
399 entry.plural_source.as_deref(),
400 translations,
401 &options.locale,
402 options.strict,
403 )?,
404 };
405
406 messages.insert(key, compiled);
407 }
408
409 Ok(CompiledCatalog {
410 messages,
411 locale: options.locale.clone(),
412 })
413}
414
415fn compile_gettext_plural_runtime(
416 msgid: &str,
417 plural_source: Option<&str>,
418 translations: &[String],
419 locale: &str,
420 strict: bool,
421) -> Result<CompiledMessage, IcuParseError> {
422 let forms = translations
423 .iter()
424 .map(|translation| {
425 compile_icu(
426 translation,
427 &CompileIcuOptions {
428 locale: locale.to_owned(),
429 strict,
430 },
431 )
432 })
433 .collect::<Result<Vec<_>, _>>()?;
434
435 Ok(CompiledMessage {
436 kind: CompiledMessageKind::GettextPlural {
437 variable: extract_plural_variable(msgid, plural_source)
438 .unwrap_or_else(|| String::from("count")),
439 forms,
440 },
441 locale: locale.to_owned(),
442 })
443}
444
445fn render_gettext_plural(
446 forms: &[CompiledMessage],
447 variable: &str,
448 values: &MessageValues,
449 host: &impl FormatHost,
450) -> String {
451 let count = values.get(variable).and_then(as_number).unwrap_or(0.0);
452 let index = get_plural_index(host.locale(), count);
453 let message = forms
454 .get(index)
455 .or_else(|| forms.last())
456 .map_or_else(String::new, |form| form.format_with_host(values, host));
457
458 if message.is_empty() && forms.is_empty() {
459 format_number(count)
460 } else {
461 message
462 }
463}
464
465fn render_nodes(
466 nodes: &[IcuNode],
467 values: &MessageValues,
468 host: &impl FormatHost,
469 plural: Option<PluralRuntime<'_>>,
470) -> String {
471 let mut output = String::new();
472 for node in nodes {
473 output.push_str(&render_node(node, values, host, plural));
474 }
475 output
476}
477
478#[derive(Clone, Copy)]
479struct PluralRuntime<'a> {
480 variable: &'a str,
481 offset: i32,
482}
483
484fn render_node(
485 node: &IcuNode,
486 values: &MessageValues,
487 host: &impl FormatHost,
488 plural: Option<PluralRuntime<'_>>,
489) -> String {
490 match node {
491 IcuNode::Literal { value } => value.clone(),
492 IcuNode::Argument { value } => values
493 .get(value)
494 .map_or_else(|| format!("{{{value}}}"), display_value),
495 IcuNode::Number { value, style } => values.get(value).map_or_else(
496 || format!("{{{value}}}"),
497 |message_value| {
498 host.format_number(value, message_value, style.as_deref(), values)
499 .unwrap_or_else(|| display_value(message_value))
500 },
501 ),
502 IcuNode::Date { value, style } => values.get(value).map_or_else(
503 || format!("{{{value}}}"),
504 |message_value| {
505 host.format_date(value, message_value, style.as_deref(), values)
506 .unwrap_or_else(|| display_value(message_value))
507 },
508 ),
509 IcuNode::Time { value, style } => values.get(value).map_or_else(
510 || format!("{{{value}}}"),
511 |message_value| {
512 host.format_time(value, message_value, style.as_deref(), values)
513 .unwrap_or_else(|| display_value(message_value))
514 },
515 ),
516 IcuNode::List { value, style } => values.get(value).map_or_else(
517 || format!("{{{value}}}"),
518 |message_value| {
519 host.format_list(value, message_value, style.as_deref(), values)
520 .unwrap_or_else(|| display_value(message_value))
521 },
522 ),
523 IcuNode::Duration { value, style } => values.get(value).map_or_else(
524 || format!("{{{value}}}"),
525 |message_value| {
526 host.format_duration(value, message_value, style.as_deref(), values)
527 .unwrap_or_else(|| display_value(message_value))
528 },
529 ),
530 IcuNode::Ago { value, style } => values.get(value).map_or_else(
531 || format!("{{{value}}}"),
532 |message_value| {
533 host.format_ago(value, message_value, style.as_deref(), values)
534 .unwrap_or_else(|| display_value(message_value))
535 },
536 ),
537 IcuNode::Name { value, style } => values.get(value).map_or_else(
538 || format!("{{{value}}}"),
539 |message_value| {
540 host.format_name(value, message_value, style.as_deref(), values)
541 .unwrap_or_else(|| display_value(message_value))
542 },
543 ),
544 IcuNode::Pound => plural
545 .and_then(|context| {
546 values
547 .get(context.variable)
548 .and_then(as_number)
549 .map(|value| (context, value))
550 })
551 .map_or_else(
552 || String::from("#"),
553 |(context, value)| format_number(value - f64::from(context.offset)),
554 ),
555 IcuNode::Select { value, options } => {
556 let selector = values.get(value).map_or_else(String::new, display_value);
557 options
558 .get(&selector)
559 .or_else(|| options.get("other"))
560 .map_or_else(
561 || format!("{{{value}}}"),
562 |option| render_nodes(&option.value, values, host, plural),
563 )
564 }
565 IcuNode::Plural {
566 value,
567 options,
568 offset,
569 ..
570 } => {
571 let Some(count) = values.get(value).and_then(as_number) else {
572 return format!("{{{value}}}");
573 };
574 if let Some(exact_key) = exact_plural_key(count) {
575 if let Some(option) = options.get(&exact_key) {
576 return render_nodes(
577 &option.value,
578 values,
579 host,
580 Some(PluralRuntime {
581 variable: value,
582 offset: *offset,
583 }),
584 );
585 }
586 }
587
588 let adjusted = count - f64::from(*offset);
589 let category_index = get_plural_index(host.locale(), adjusted);
590 let category = get_plural_categories(host.locale())
591 .get(category_index)
592 .copied()
593 .unwrap_or("other");
594 options
595 .get(category)
596 .or_else(|| options.get("other"))
597 .map_or_else(
598 || format!("{{{value}}}"),
599 |option| {
600 render_nodes(
601 &option.value,
602 values,
603 host,
604 Some(PluralRuntime {
605 variable: value,
606 offset: *offset,
607 }),
608 )
609 },
610 )
611 }
612 IcuNode::Tag { value, children } => {
613 let child_text = render_nodes(children, values, host, plural);
614 host.render_tag(value, &child_text, values)
615 .unwrap_or(child_text)
616 }
617 }
618}
619
620fn display_value(value: &MessageValue) -> String {
621 match value {
622 MessageValue::String(value) => value.clone(),
623 MessageValue::Number(value) => format_number(*value),
624 MessageValue::Bool(value) => value.to_string(),
625 MessageValue::List(values) => values
626 .iter()
627 .map(display_value)
628 .collect::<Vec<_>>()
629 .join(", "),
630 MessageValue::Tag(_) => String::new(),
631 }
632}
633
634fn format_number(value: f64) -> String {
635 if value.fract() == 0.0 {
636 format!("{value:.0}")
637 } else {
638 value.to_string()
639 }
640}
641
642fn as_number(value: &MessageValue) -> Option<f64> {
643 match value {
644 MessageValue::Number(value) => Some(*value),
645 _ => None,
646 }
647}
648
649fn exact_plural_key(value: f64) -> Option<String> {
650 if value.fract() == 0.0 {
651 Some(format!("={value:.0}"))
652 } else {
653 None
654 }
655}
656
657fn extract_plural_variable(msgid: &str, plural_source: Option<&str>) -> Option<String> {
658 plural_source
659 .and_then(find_braced_identifier)
660 .or_else(|| find_braced_identifier(msgid))
661}
662
663fn find_braced_identifier(input: &str) -> Option<String> {
664 let start = input.find('{')?;
665 let end = input[start + 1..].find('}')? + start + 1;
666 let candidate = input[start + 1..end].trim();
667 if candidate.is_empty() {
668 None
669 } else {
670 Some(candidate.to_owned())
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use std::collections::BTreeMap;
677 use std::sync::Arc;
678
679 use super::{
680 compile_catalog, compile_icu, CompileCatalogOptions, CompileIcuOptions, CompiledCatalog,
681 FormatHost, MessageValue, MessageValues,
682 };
683 use crate::catalog::{Catalog, CatalogEntry, CatalogTranslation};
684
685 fn values(entries: &[(&str, MessageValue)]) -> MessageValues {
686 entries
687 .iter()
688 .map(|(key, value)| ((*key).to_owned(), value.clone()))
689 .collect()
690 }
691
692 #[test]
693 fn compile_icu_formats_literals_arguments_and_plurals() {
694 let compiled = compile_icu(
695 "Hello {name}! {count, plural, one {# item} other {# items}}",
696 &CompileIcuOptions::new("en"),
697 )
698 .expect("should compile");
699
700 let rendered = compiled.format(&values(&[
701 ("name", MessageValue::from("World")),
702 ("count", MessageValue::from(5usize)),
703 ]));
704 assert_eq!(rendered, "Hello World! 5 items");
705 }
706
707 #[test]
708 fn compile_icu_handles_select_and_tags() {
709 let compiled = compile_icu(
710 "{gender, select, male {He} other {<b>They</b>}}",
711 &CompileIcuOptions::new("en"),
712 )
713 .expect("should compile");
714
715 let rendered = compiled.format(&values(&[
716 ("gender", MessageValue::from("other")),
717 (
718 "b",
719 MessageValue::Tag(Arc::new(|text: &str| format!("[{text}]"))),
720 ),
721 ]));
722 assert_eq!(rendered, "[They]");
723 }
724
725 #[test]
726 fn compile_icu_supports_custom_host_formatters_and_locale() {
727 struct TestHost;
728
729 impl FormatHost for TestHost {
730 fn locale(&self) -> &str {
731 "pl"
732 }
733
734 fn format_number(
735 &self,
736 _name: &str,
737 value: &MessageValue,
738 _style: Option<&str>,
739 _values: &MessageValues,
740 ) -> Option<String> {
741 match value {
742 MessageValue::Number(number) => Some(format!("n={number:.1}")),
743 _ => None,
744 }
745 }
746
747 fn render_tag(
748 &self,
749 _name: &str,
750 children: &str,
751 _values: &MessageValues,
752 ) -> Option<String> {
753 Some(format!("<{children}>"))
754 }
755 }
756
757 let compiled = compile_icu(
758 "{count, plural, one {<b>{count, number}</b> file} few {<b>{count, number}</b> files} other {<b>{count, number}</b> files}}",
759 &CompileIcuOptions::new("en"),
760 )
761 .expect("should compile");
762
763 let rendered =
764 compiled.format_with_host(&values(&[("count", MessageValue::from(2usize))]), &TestHost);
765
766 assert_eq!(rendered, "<n=2.0> files");
767 }
768
769 #[test]
770 fn compile_icu_falls_back_when_not_strict() {
771 let compiled = compile_icu(
772 "{invalid",
773 &CompileIcuOptions {
774 locale: String::from("en"),
775 strict: false,
776 },
777 )
778 .expect("should fall back");
779
780 assert_eq!(compiled.format(&MessageValues::new()), "{invalid");
781 }
782
783 #[test]
784 fn compile_catalog_formats_messages_and_uses_keys() {
785 let catalog = Catalog::from([(
786 String::from("Hello {name}!"),
787 CatalogEntry {
788 translation: Some(CatalogTranslation::Singular(String::from("Hallo {name}!"))),
789 ..CatalogEntry::default()
790 },
791 )]);
792
793 let compiled = compile_catalog(&catalog, &CompileCatalogOptions::new("de"))
794 .expect("catalog should compile");
795 let key = compiled
796 .keys()
797 .into_iter()
798 .next()
799 .expect("key should exist");
800 assert_eq!(
801 compiled.format(&key, &values(&[("name", MessageValue::from("Sebastian"))])),
802 "Hallo Sebastian!"
803 );
804 }
805
806 #[test]
807 fn compile_catalog_handles_gettext_plural_arrays() {
808 let catalog = Catalog::from([(
809 String::from("{count} item"),
810 CatalogEntry {
811 translation: Some(CatalogTranslation::Plural(vec![
812 String::from("{count} Artikel"),
813 String::from("{count} Artikel"),
814 ])),
815 plural_source: Some(String::from("{count} items")),
816 ..CatalogEntry::default()
817 },
818 )]);
819
820 let compiled = compile_catalog(&catalog, &CompileCatalogOptions::new("de"))
821 .expect("catalog should compile");
822 let key = compiled
823 .keys()
824 .into_iter()
825 .next()
826 .expect("key should exist");
827
828 assert_eq!(
829 compiled.format(&key, &values(&[("count", MessageValue::from(1usize))])),
830 "1 Artikel"
831 );
832 assert_eq!(
833 compiled.format(&key, &values(&[("count", MessageValue::from(5usize))])),
834 "5 Artikel"
835 );
836 }
837
838 #[test]
839 fn compiled_catalog_returns_key_for_missing_messages() {
840 let compiled = CompiledCatalog {
841 messages: BTreeMap::new(),
842 locale: String::from("de"),
843 };
844 assert_eq!(compiled.format("missing", &MessageValues::new()), "missing");
845 }
846}