Skip to main content

ferrocat_icu/
serialize.rs

1use crate::{IcuMessage, IcuNode, IcuPluralKind};
2
3/// Renders a parsed ICU message AST back to ICU `MessageFormat` text.
4///
5/// The output is canonical rather than byte-preserving: insignificant
6/// whitespace and quote choices may differ from the source input, but parsing
7/// the rendered string yields the same AST for the currently supported node
8/// variants.
9#[must_use]
10pub fn stringify_icu(message: &IcuMessage) -> String {
11    let mut out = String::new();
12    render_nodes(&message.nodes, &mut out);
13    out
14}
15
16fn render_nodes(nodes: &[IcuNode], out: &mut String) {
17    for node in nodes {
18        render_node(node, out);
19    }
20}
21
22fn render_node(node: &IcuNode, out: &mut String) {
23    match node {
24        IcuNode::Literal(value) => append_escaped_literal(out, value),
25        IcuNode::Argument { name } => {
26            out.push('{');
27            out.push_str(name);
28            out.push('}');
29        }
30        IcuNode::Number { name, style } => render_formatter("number", name, style.as_deref(), out),
31        IcuNode::Date { name, style } => render_formatter("date", name, style.as_deref(), out),
32        IcuNode::Time { name, style } => render_formatter("time", name, style.as_deref(), out),
33        IcuNode::List { name, style } => render_formatter("list", name, style.as_deref(), out),
34        IcuNode::Duration { name, style } => {
35            render_formatter("duration", name, style.as_deref(), out);
36        }
37        IcuNode::Ago { name, style } => render_formatter("ago", name, style.as_deref(), out),
38        IcuNode::Name { name, style } => render_formatter("name", name, style.as_deref(), out),
39        IcuNode::Select { name, options } => {
40            out.push('{');
41            out.push_str(name);
42            out.push_str(", select, ");
43            render_options(options, out);
44            out.push('}');
45        }
46        IcuNode::Plural {
47            name,
48            kind,
49            offset,
50            options,
51        } => {
52            out.push('{');
53            out.push_str(name);
54            out.push_str(match kind {
55                IcuPluralKind::Cardinal => ", plural, ",
56                IcuPluralKind::Ordinal => ", selectordinal, ",
57            });
58            if *offset > 0 {
59                out.push_str("offset:");
60                out.push_str(&offset.to_string());
61                out.push(' ');
62            }
63            render_options(options, out);
64            out.push('}');
65        }
66        IcuNode::Pound => out.push('#'),
67        IcuNode::Tag { name, children } => {
68            out.push('<');
69            out.push_str(name);
70            out.push('>');
71            render_nodes(children, out);
72            out.push_str("</");
73            out.push_str(name);
74            out.push('>');
75        }
76    }
77}
78
79fn render_options(options: &[crate::IcuOption], out: &mut String) {
80    for (index, option) in options.iter().enumerate() {
81        if index > 0 {
82            out.push(' ');
83        }
84        out.push_str(&option.selector);
85        out.push_str(" {");
86        render_nodes(&option.value, out);
87        out.push('}');
88    }
89}
90
91fn render_formatter(kind: &str, name: &str, style: Option<&str>, out: &mut String) {
92    out.push('{');
93    out.push_str(name);
94    out.push_str(", ");
95    out.push_str(kind);
96    if let Some(style) = style {
97        out.push_str(", ");
98        out.push_str(style);
99    }
100    out.push('}');
101}
102
103fn append_escaped_literal(out: &mut String, value: &str) {
104    if !value
105        .as_bytes()
106        .iter()
107        .any(|byte| matches!(byte, b'\'' | b'{' | b'}' | b'#' | b'<' | b'>'))
108    {
109        out.push_str(value);
110        return;
111    }
112
113    for ch in value.chars() {
114        match ch {
115            '\'' => out.push_str("''"),
116            '{' | '}' | '#' | '<' | '>' => {
117                out.push('\'');
118                out.push(ch);
119                out.push('\'');
120            }
121            _ => out.push(ch),
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use crate::{parse_icu, stringify_icu};
129
130    fn assert_roundtrip(input: &str) {
131        let parsed = parse_icu(input).expect("parse source");
132        let rendered = stringify_icu(&parsed);
133        let reparsed = parse_icu(&rendered).expect("parse rendered");
134        assert_eq!(reparsed, parsed, "rendered: {rendered}");
135    }
136
137    #[test]
138    fn roundtrips_literal_escaping_arguments_and_formatters() {
139        assert_roundtrip("Use '<'tag'>' with '{'braces'}', ''quotes'', and '#'.");
140        assert_roundtrip(
141            "{name} {count, number, integer} {created, date, short} {created, time, ::HHmm}",
142        );
143        assert_roundtrip("{items, list, conjunction} {elapsed, duration} {age, ago} {owner, name}");
144    }
145
146    #[test]
147    fn roundtrips_select_plural_pound_and_tags() {
148        assert_roundtrip(
149            "{gender, select, female {She liked <b>{thing}</b>} male {He liked <b>{thing}</b>} other {They liked <b>{thing}</b>}}",
150        );
151        assert_roundtrip(
152            "{count, plural, offset:1 =0 {No items} one {# item} other {# items and '#'}}",
153        );
154        assert_roundtrip(
155            "{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}",
156        );
157    }
158
159    #[test]
160    fn uses_canonical_spacing_for_rendered_messages() {
161        let parsed = parse_icu("{count,plural,one{# item} other{# items}}").expect("parse");
162
163        assert_eq!(
164            stringify_icu(&parsed),
165            "{count, plural, one {# item} other {# items}}"
166        );
167    }
168}