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            if children.is_empty() {
71                out.push_str("/>");
72            } else {
73                out.push('>');
74                render_nodes(children, out);
75                out.push_str("</");
76                out.push_str(name);
77                out.push('>');
78            }
79        }
80    }
81}
82
83fn render_options(options: &[crate::IcuOption], out: &mut String) {
84    for (index, option) in options.iter().enumerate() {
85        if index > 0 {
86            out.push(' ');
87        }
88        out.push_str(&option.selector);
89        out.push_str(" {");
90        render_nodes(&option.value, out);
91        out.push('}');
92    }
93}
94
95fn render_formatter(kind: &str, name: &str, style: Option<&str>, out: &mut String) {
96    out.push('{');
97    out.push_str(name);
98    out.push_str(", ");
99    out.push_str(kind);
100    if let Some(style) = style {
101        out.push_str(", ");
102        out.push_str(style);
103    }
104    out.push('}');
105}
106
107fn append_escaped_literal(out: &mut String, value: &str) {
108    if !value
109        .as_bytes()
110        .iter()
111        .any(|byte| matches!(byte, b'\'' | b'{' | b'}' | b'#' | b'<' | b'>'))
112    {
113        out.push_str(value);
114        return;
115    }
116
117    for ch in value.chars() {
118        match ch {
119            '\'' => out.push_str("''"),
120            '{' | '}' | '#' | '<' | '>' => {
121                out.push('\'');
122                out.push(ch);
123                out.push('\'');
124            }
125            _ => out.push(ch),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use crate::{parse_icu, stringify_icu};
133
134    fn assert_roundtrip(input: &str) {
135        let parsed = parse_icu(input).expect("parse source");
136        let rendered = stringify_icu(&parsed);
137        let reparsed = parse_icu(&rendered).expect("parse rendered");
138        assert_eq!(reparsed, parsed, "rendered: {rendered}");
139    }
140
141    #[test]
142    fn roundtrips_literal_escaping_arguments_and_formatters() {
143        assert_roundtrip("Use '<'tag'>' with '{'braces'}', ''quotes'', and '#'.");
144        assert_roundtrip(
145            "{name} {count, number, integer} {created, date, short} {created, time, ::HHmm}",
146        );
147        assert_roundtrip("{items, list, conjunction} {elapsed, duration} {age, ago} {owner, name}");
148    }
149
150    #[test]
151    fn roundtrips_select_plural_pound_and_tags() {
152        assert_roundtrip(
153            "{gender, select, female {She liked <b>{thing}</b>} male {He liked <b>{thing}</b>} other {They liked <b>{thing}</b>}}",
154        );
155        assert_roundtrip(
156            "{count, plural, offset:1 =0 {No items} one {# item} other {# items and '#'}}",
157        );
158        assert_roundtrip(
159            "{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}",
160        );
161    }
162
163    #[test]
164    fn uses_canonical_spacing_for_rendered_messages() {
165        let parsed = parse_icu("{count,plural,one{# item} other{# items}}").expect("parse");
166
167        assert_eq!(
168            stringify_icu(&parsed),
169            "{count, plural, one {# item} other {# items}}"
170        );
171    }
172}