Skip to main content

pofile/
compile.rs

1//! Runtime ICU and catalog compilation.
2
3use 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
12/// Host interface used for locale-aware formatting and tag rendering.
13pub trait FormatHost {
14    /// Locale used by plural selection and host-provided formatters.
15    fn locale(&self) -> &str;
16
17    /// Format a number node value.
18    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    /// Format a date node value.
29    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    /// Format a time node value.
40    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    /// Format a list node value.
51    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    /// Format a duration node value.
62    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    /// Format a relative-time node value.
73    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    /// Format a display-name node value.
84    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    /// Render a tag node.
95    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/// Default host used by [`CompiledMessage::format`] and [`CompiledCatalog::format`].
104#[derive(Debug, Clone)]
105pub struct DefaultFormatHost {
106    locale: String,
107}
108
109impl DefaultFormatHost {
110    /// Create a default host for a locale.
111    #[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
125/// Trait implemented by tag handlers used during message rendering.
126pub trait TagHandler: Send + Sync {
127    /// Render child text for a tag.
128    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
140/// Value map passed to compiled messages.
141pub type MessageValues = BTreeMap<String, MessageValue>;
142
143/// Runtime value used by the Rust compiler/evaluator.
144pub enum MessageValue {
145    /// Text value.
146    String(String),
147    /// Numeric value.
148    Number(f64),
149    /// Boolean value.
150    Bool(bool),
151    /// List value.
152    List(Vec<MessageValue>),
153    /// Tag handler.
154    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/// Options for ICU runtime compilation.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct CompileIcuOptions {
220    /// Locale used for plural selection.
221    pub locale: String,
222    /// Whether parse errors should be returned instead of falling back.
223    pub strict: bool,
224}
225
226impl CompileIcuOptions {
227    /// Create compiler options for a locale.
228    #[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/// A compiled message.
238#[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    /// Format the compiled message with the given values.
256    #[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    /// Format the compiled message with a caller-provided host.
263    #[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
275/// Compile a single ICU message.
276pub 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/// Options for catalog compilation.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct CompileCatalogOptions {
296    /// Locale used for plural selection.
297    pub locale: String,
298    /// Use generated message IDs as keys.
299    pub use_message_id: bool,
300    /// Whether ICU parse errors are fatal.
301    pub strict: bool,
302}
303
304impl CompileCatalogOptions {
305    /// Create catalog compiler options for a locale.
306    #[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/// A compiled catalog with lookup and formatting.
317#[derive(Debug, Clone)]
318pub struct CompiledCatalog {
319    messages: BTreeMap<String, CompiledMessage>,
320    /// Locale associated with the compiled catalog.
321    pub locale: String,
322}
323
324impl CompiledCatalog {
325    /// Get a compiled message by key.
326    #[must_use]
327    pub fn get(&self, key: &str) -> Option<&CompiledMessage> {
328        self.messages.get(key)
329    }
330
331    /// Format a message by key, returning the key itself if missing.
332    #[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    /// Format a message with a caller-provided host, returning the key itself if missing.
339    #[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    /// Check whether a message exists.
353    #[must_use]
354    pub fn has(&self, key: &str) -> bool {
355        self.messages.contains_key(key)
356    }
357
358    /// Get all message keys.
359    #[must_use]
360    pub fn keys(&self) -> Vec<String> {
361        self.messages.keys().cloned().collect()
362    }
363
364    /// Number of compiled messages.
365    #[must_use]
366    pub fn size(&self) -> usize {
367        self.messages.len()
368    }
369}
370
371/// Compile a catalog to a runtime evaluator map.
372pub 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}