Skip to main content

ferrocat_icu/
parser.rs

1use crate::ast::{IcuMessage, IcuNode, IcuOption, IcuPluralKind};
2use crate::error::IcuParseError;
3
4/// Options controlling ICU parsing behavior.
5#[derive(Debug, Clone, PartialEq, Eq)]
6#[non_exhaustive]
7pub struct IcuParserOptions {
8    /// When `true`, rich-text style tags are treated as plain text.
9    pub ignore_tag: bool,
10    /// When `true`, select and plural arguments must include an `other` clause.
11    pub requires_other_clause: bool,
12}
13
14impl Default for IcuParserOptions {
15    fn default() -> Self {
16        Self {
17            ignore_tag: false,
18            requires_other_clause: true,
19        }
20    }
21}
22
23impl IcuParserOptions {
24    /// Returns options that enable or disable rich-text tag parsing.
25    #[must_use]
26    pub fn with_ignore_tag(mut self, ignore_tag: bool) -> Self {
27        self.ignore_tag = ignore_tag;
28        self
29    }
30
31    /// Returns options that enable or disable required `other` clauses.
32    #[must_use]
33    pub fn with_requires_other_clause(mut self, requires_other_clause: bool) -> Self {
34        self.requires_other_clause = requires_other_clause;
35        self
36    }
37}
38
39/// Parses ICU `MessageFormat` input with the default parser options.
40///
41/// # Errors
42///
43/// Returns [`IcuParseError`] when the input is malformed.
44pub fn parse_icu(input: &str) -> Result<IcuMessage, IcuParseError> {
45    parse_icu_with_options(input, &IcuParserOptions::default())
46}
47
48/// Parses ICU `MessageFormat` input with explicit parser options.
49///
50/// # Errors
51///
52/// Returns [`IcuParseError`] when the input is malformed.
53pub fn parse_icu_with_options(
54    input: &str,
55    options: &IcuParserOptions,
56) -> Result<IcuMessage, IcuParseError> {
57    let mut parser = Parser::new(input, options);
58    let nodes = parser.parse_nodes(None, 0)?;
59    if !parser.is_eof() {
60        return Err(parser.error("Unexpected trailing input"));
61    }
62    Ok(IcuMessage { nodes })
63}
64
65struct Parser<'a> {
66    input: &'a str,
67    input_bytes: &'a [u8],
68    pos: usize,
69    options: &'a IcuParserOptions,
70}
71
72impl<'a> Parser<'a> {
73    const OFFSET_PREFIX: &'static [u8] = b"offset:";
74    const CLOSE_TAG_PREFIX: &'static [u8] = b"</";
75    const SELF_CLOSING_TAG_SUFFIX: &'static [u8] = b"/>";
76
77    const fn new(input: &'a str, options: &'a IcuParserOptions) -> Self {
78        Self {
79            input,
80            input_bytes: input.as_bytes(),
81            pos: 0,
82            options,
83        }
84    }
85
86    fn parse_nodes(
87        &mut self,
88        until_tag: Option<&str>,
89        plural_depth: usize,
90    ) -> Result<Vec<IcuNode>, IcuParseError> {
91        let mut nodes = Vec::with_capacity(4);
92        let mut literal = String::with_capacity(16);
93
94        while let Some(byte) = self.byte_at() {
95            if byte == b'}' {
96                break;
97            }
98
99            if let Some(tag_name) = until_tag {
100                if self.starts_with_close_tag(tag_name) {
101                    break;
102                }
103                if !self.options.ignore_tag && self.peek_close_tag() {
104                    return Err(self.error("Mismatched closing tag"));
105                }
106            } else if !self.options.ignore_tag && self.peek_close_tag() {
107                return Err(self.error("Unexpected closing tag"));
108            }
109
110            match byte {
111                b'{' => {
112                    Self::flush_literal(&mut literal, &mut nodes);
113                    nodes.push(self.parse_argument(plural_depth)?);
114                }
115                b'<' if !self.options.ignore_tag && self.peek_open_tag() => {
116                    Self::flush_literal(&mut literal, &mut nodes);
117                    nodes.push(self.parse_tag(plural_depth)?);
118                }
119                b'#' if plural_depth > 0 => {
120                    Self::flush_literal(&mut literal, &mut nodes);
121                    self.pos += 1;
122                    nodes.push(IcuNode::Pound);
123                }
124                b'\'' => literal.push_str(&self.parse_apostrophe_literal()?),
125                b'<' => {
126                    // A `<` that is not an open tag (close tags are handled above):
127                    // copy it as a literal byte and continue scanning.
128                    literal.push('<');
129                    self.pos += 1;
130                }
131                _ => {
132                    // Bulk-copy the literal run up to the next syntactically significant
133                    // byte (memchr/SIMD jump) instead of decoding+copying char by char.
134                    let rest = &self.input_bytes[self.pos..];
135                    let stop = find_literal_stop(rest, plural_depth > 0)
136                        .unwrap_or(rest.len())
137                        .max(1);
138                    literal.push_str(&self.input[self.pos..self.pos + stop]);
139                    self.pos += stop;
140                }
141            }
142        }
143
144        Self::flush_literal(&mut literal, &mut nodes);
145        Ok(nodes)
146    }
147
148    fn parse_argument(&mut self, plural_depth: usize) -> Result<IcuNode, IcuParseError> {
149        self.expect_char('{')?;
150        self.skip_whitespace();
151        let name = self.parse_identifier()?;
152        self.skip_whitespace();
153
154        if self.consume_char('}') {
155            return Ok(IcuNode::Argument { name });
156        }
157
158        self.expect_char(',')?;
159        self.skip_whitespace();
160        let kind = self.parse_identifier()?;
161        self.skip_whitespace();
162
163        match kind.as_str() {
164            "number" => self.parse_simple_formatter(name, FormatterKind::Number),
165            "date" => self.parse_simple_formatter(name, FormatterKind::Date),
166            "time" => self.parse_simple_formatter(name, FormatterKind::Time),
167            "list" => self.parse_simple_formatter(name, FormatterKind::List),
168            "duration" => self.parse_simple_formatter(name, FormatterKind::Duration),
169            "ago" => self.parse_simple_formatter(name, FormatterKind::Ago),
170            "name" => self.parse_simple_formatter(name, FormatterKind::Name),
171            "select" => self.parse_select(name, plural_depth),
172            "plural" => self.parse_plural(name, plural_depth, IcuPluralKind::Cardinal),
173            "selectordinal" => self.parse_plural(name, plural_depth, IcuPluralKind::Ordinal),
174            _ => Err(self.error("Unsupported ICU argument type")),
175        }
176    }
177
178    fn parse_simple_formatter(
179        &mut self,
180        name: String,
181        kind: FormatterKind,
182    ) -> Result<IcuNode, IcuParseError> {
183        let style = if self.consume_char(',') {
184            let style = self.read_until_closing_brace()?.trim().to_owned();
185            Some(style).filter(|style| !style.is_empty())
186        } else {
187            None
188        };
189        self.expect_char('}')?;
190
191        Ok(match kind {
192            FormatterKind::Number => IcuNode::Number { name, style },
193            FormatterKind::Date => IcuNode::Date { name, style },
194            FormatterKind::Time => IcuNode::Time { name, style },
195            FormatterKind::List => IcuNode::List { name, style },
196            FormatterKind::Duration => IcuNode::Duration { name, style },
197            FormatterKind::Ago => IcuNode::Ago { name, style },
198            FormatterKind::Name => IcuNode::Name { name, style },
199        })
200    }
201
202    fn parse_select(
203        &mut self,
204        name: String,
205        plural_depth: usize,
206    ) -> Result<IcuNode, IcuParseError> {
207        if self.consume_char(',') {
208            self.skip_whitespace();
209        }
210        let options = self.parse_options(plural_depth)?;
211        if self.options.requires_other_clause && !has_other_clause(&options) {
212            return Err(self.error("Select argument requires an \"other\" clause"));
213        }
214        self.expect_char('}')?;
215        Ok(IcuNode::Select { name, options })
216    }
217
218    fn parse_plural(
219        &mut self,
220        name: String,
221        plural_depth: usize,
222        kind: IcuPluralKind,
223    ) -> Result<IcuNode, IcuParseError> {
224        let mut offset = 0u32;
225
226        if self.consume_char(',') {
227            self.skip_whitespace();
228        }
229
230        loop {
231            self.skip_whitespace();
232            if self.starts_with_bytes(Self::OFFSET_PREFIX) {
233                self.pos += Self::OFFSET_PREFIX.len();
234                self.skip_whitespace();
235                offset = self.parse_unsigned_int()?;
236            } else {
237                break;
238            }
239        }
240
241        let options = self.parse_options(plural_depth + 1)?;
242        if self.options.requires_other_clause && !has_other_clause(&options) {
243            return Err(self.error("Plural argument requires an \"other\" clause"));
244        }
245        self.expect_char('}')?;
246
247        Ok(IcuNode::Plural {
248            name,
249            kind,
250            offset,
251            options,
252        })
253    }
254
255    fn parse_options(&mut self, plural_depth: usize) -> Result<Vec<IcuOption>, IcuParseError> {
256        let mut options = Vec::with_capacity(4);
257
258        loop {
259            self.skip_whitespace();
260            if self.byte_at() == Some(b'}') {
261                break;
262            }
263            let selector = self.parse_selector()?;
264            self.skip_whitespace();
265            self.expect_char('{')?;
266            let value = self.parse_nodes(None, plural_depth)?;
267            self.expect_char('}')?;
268            options.push(IcuOption { selector, value });
269        }
270
271        if options.is_empty() {
272            return Err(self.error("Expected at least one ICU option"));
273        }
274
275        Ok(options)
276    }
277
278    fn parse_tag(&mut self, plural_depth: usize) -> Result<IcuNode, IcuParseError> {
279        self.expect_char('<')?;
280        let name = self.parse_tag_name()?;
281        if self.starts_with_bytes(Self::SELF_CLOSING_TAG_SUFFIX) {
282            self.pos += Self::SELF_CLOSING_TAG_SUFFIX.len();
283            return Ok(IcuNode::Tag {
284                name,
285                children: Vec::new(),
286            });
287        }
288        self.expect_char('>')?;
289        let children = self.parse_nodes(Some(&name), plural_depth)?;
290        self.expect_bytes(Self::CLOSE_TAG_PREFIX)?;
291        let close_name = self.parse_tag_name()?;
292        if close_name != name {
293            return Err(self.error("Mismatched closing tag"));
294        }
295        self.expect_char('>')?;
296        Ok(IcuNode::Tag { name, children })
297    }
298
299    fn parse_apostrophe_literal(&mut self) -> Result<String, IcuParseError> {
300        let start = self.pos;
301        self.expect_char('\'')?;
302
303        if self.consume_char('\'') {
304            return Ok("'".to_owned());
305        }
306
307        let mut out = String::with_capacity(8);
308        while let Some(byte) = self.byte_at() {
309            if byte == b'\'' {
310                self.pos += 1;
311                if self.consume_char('\'') {
312                    out.push('\'');
313                } else {
314                    return Ok(out);
315                }
316            } else {
317                let rest = &self.input_bytes[self.pos..];
318                let stop = memchr::memchr(b'\'', rest).unwrap_or(rest.len()).max(1);
319                out.push_str(&self.input[self.pos..self.pos + stop]);
320                self.pos += stop;
321            }
322        }
323
324        Err(IcuParseError::syntax(
325            "Unterminated apostrophe escape",
326            self.input,
327            start,
328        ))
329    }
330
331    fn read_until_closing_brace(&mut self) -> Result<String, IcuParseError> {
332        let mut out = String::with_capacity(8);
333        while let Some(byte) = self.byte_at() {
334            if byte == b'}' {
335                return Ok(out);
336            }
337            if byte == b'\'' {
338                out.push_str(&self.parse_apostrophe_literal()?);
339            } else {
340                let rest = &self.input_bytes[self.pos..];
341                let stop = memchr::memchr2(b'}', b'\'', rest)
342                    .unwrap_or(rest.len())
343                    .max(1);
344                out.push_str(&self.input[self.pos..self.pos + stop]);
345                self.pos += stop;
346            }
347        }
348        Err(self.error("Unterminated ICU argument"))
349    }
350
351    fn parse_selector(&mut self) -> Result<String, IcuParseError> {
352        let start = self.pos;
353        if self.consume_char('=') {
354            let number = self.parse_unsigned_int()?;
355            return Ok(format!("={number}"));
356        }
357
358        while let Some(byte) = self.byte_at() {
359            if byte.is_ascii_whitespace() || byte == b'{' {
360                break;
361            }
362            if byte.is_ascii() {
363                self.pos += 1;
364            } else {
365                self.advance_char();
366            }
367        }
368
369        if self.pos == start {
370            return Err(self.error("Expected ICU selector"));
371        }
372
373        Ok(self.input[start..self.pos].to_owned())
374    }
375
376    fn parse_identifier(&mut self) -> Result<String, IcuParseError> {
377        let start = self.pos;
378        while let Some(byte) = self.byte_at() {
379            if byte.is_ascii_whitespace() || matches!(byte, b'{' | b'}' | b',' | b'<' | b'>') {
380                break;
381            }
382            if byte.is_ascii() {
383                self.pos += 1;
384            } else {
385                self.advance_char();
386            }
387        }
388
389        if self.pos == start {
390            return Err(self.error("Expected ICU identifier"));
391        }
392
393        Ok(self.input[start..self.pos].to_owned())
394    }
395
396    fn parse_tag_name(&mut self) -> Result<String, IcuParseError> {
397        let start = self.pos;
398        while let Some(byte) = self.byte_at() {
399            if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.') {
400                self.pos += 1;
401            } else {
402                break;
403            }
404        }
405
406        if self.pos == start {
407            return Err(self.error("Expected tag name"));
408        }
409
410        Ok(self.input[start..self.pos].to_owned())
411    }
412
413    fn parse_unsigned_int(&mut self) -> Result<u32, IcuParseError> {
414        let start = self.pos;
415        while let Some(byte) = self.byte_at() {
416            if byte.is_ascii_digit() {
417                self.pos += 1;
418            } else {
419                break;
420            }
421        }
422
423        if self.pos == start {
424            return Err(self.error("Expected integer"));
425        }
426
427        self.input[start..self.pos]
428            .parse::<u32>()
429            .map_err(|_| self.error("Invalid integer"))
430    }
431
432    fn skip_whitespace(&mut self) {
433        while let Some(byte) = self.byte_at() {
434            if byte.is_ascii_whitespace() {
435                self.pos += 1;
436            } else {
437                break;
438            }
439        }
440    }
441
442    fn flush_literal(literal: &mut String, nodes: &mut Vec<IcuNode>) {
443        if !literal.is_empty() {
444            nodes.push(IcuNode::Literal(core::mem::take(literal)));
445        }
446    }
447
448    fn expect_char(&mut self, ch: char) -> Result<(), IcuParseError> {
449        if ch.is_ascii() {
450            if self.byte_at() == Some(ch as u8) {
451                self.pos += 1;
452                return Ok(());
453            }
454            return Err(self.error(format!("Expected '{ch}'")));
455        }
456
457        match self.peek_char() {
458            Some(current) if current == ch => {
459                self.pos += ch.len_utf8();
460                Ok(())
461            }
462            _ => Err(self.error(format!("Expected '{ch}'"))),
463        }
464    }
465
466    fn expect_bytes(&mut self, expected: &[u8]) -> Result<(), IcuParseError> {
467        if self.starts_with_bytes(expected) {
468            self.pos += expected.len();
469            Ok(())
470        } else {
471            let expected = core::str::from_utf8(expected).unwrap_or("<bytes>");
472            Err(self.error(format!("Expected \"{expected}\"")))
473        }
474    }
475
476    fn consume_char(&mut self, ch: char) -> bool {
477        if ch.is_ascii() {
478            if self.byte_at() == Some(ch as u8) {
479                self.pos += 1;
480                return true;
481            }
482            return false;
483        }
484
485        if self.peek_char() == Some(ch) {
486            self.pos += ch.len_utf8();
487            true
488        } else {
489            false
490        }
491    }
492
493    fn peek_char(&self) -> Option<char> {
494        self.input[self.pos..].chars().next()
495    }
496
497    fn byte_at(&self) -> Option<u8> {
498        self.input_bytes.get(self.pos).copied()
499    }
500
501    fn advance_char(&mut self) -> Option<char> {
502        let ch = self.peek_char()?;
503        self.pos += ch.len_utf8();
504        Some(ch)
505    }
506
507    fn peek_open_tag(&self) -> bool {
508        let Some(rest) = self.input_bytes.get(self.pos..) else {
509            return false;
510        };
511        if !rest.starts_with(b"<") || rest.starts_with(b"</") {
512            return false;
513        }
514        rest.get(1).is_some_and(u8::is_ascii_alphanumeric)
515    }
516
517    fn peek_close_tag(&self) -> bool {
518        self.input_bytes[self.pos..].starts_with(b"</")
519    }
520
521    fn starts_with_close_tag(&self, name: &str) -> bool {
522        let Some(rest) = self.input_bytes.get(self.pos..) else {
523            return false;
524        };
525        rest.starts_with(Self::CLOSE_TAG_PREFIX)
526            && rest[2..].starts_with(name.as_bytes())
527            && rest.get(2 + name.len()) == Some(&b'>')
528    }
529
530    fn starts_with_bytes(&self, expected: &[u8]) -> bool {
531        self.input_bytes[self.pos..].starts_with(expected)
532    }
533
534    const fn is_eof(&self) -> bool {
535        self.pos >= self.input.len()
536    }
537
538    fn error(&self, message: impl Into<String>) -> IcuParseError {
539        IcuParseError::syntax(message, self.input, self.pos)
540    }
541}
542
543#[derive(Clone, Copy)]
544enum FormatterKind {
545    Number,
546    Date,
547    Time,
548    List,
549    Duration,
550    Ago,
551    Name,
552}
553
554fn has_other_clause(options: &[IcuOption]) -> bool {
555    options.iter().any(|option| option.selector == "other")
556}
557
558/// Finds the offset of the next byte that ends a plain-literal run: an ICU
559/// structural byte (`{`, `}`, `<`, `'`) or, inside a plural/selectordinal body,
560/// `#`. Uses memchr (SIMD) so long literal segments are skipped in bulk.
561#[inline]
562fn find_literal_stop(haystack: &[u8], in_plural: bool) -> Option<usize> {
563    let structural = memchr::memchr3(b'{', b'}', b'<', haystack);
564    let search_end = structural.unwrap_or(haystack.len());
565    let literal_prefix = &haystack[..search_end];
566    let quote = memchr::memchr(b'\'', literal_prefix);
567    let stop = min_option(structural, quote);
568    if in_plural {
569        min_option(stop, memchr::memchr(b'#', literal_prefix))
570    } else {
571        stop
572    }
573}
574
575#[inline]
576fn min_option(first: Option<usize>, second: Option<usize>) -> Option<usize> {
577    match (first, second) {
578        (Some(left), Some(right)) => Some(left.min(right)),
579        (Some(value), None) | (None, Some(value)) => Some(value),
580        (None, None) => None,
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use crate::{
587        IcuNode, IcuParseError, IcuParserOptions, IcuPluralKind, parse_icu, parse_icu_with_options,
588        validate_icu,
589    };
590
591    use super::find_literal_stop;
592
593    #[test]
594    fn parses_simple_argument_message() {
595        let message = parse_icu("Hello {name}!").expect("parse");
596        assert_eq!(
597            message.nodes,
598            vec![
599                IcuNode::Literal("Hello ".to_owned()),
600                IcuNode::Argument {
601                    name: "name".to_owned()
602                },
603                IcuNode::Literal("!".to_owned())
604            ]
605        );
606    }
607
608    #[test]
609    fn parses_formatter_styles_as_opaque_strings() {
610        let message = parse_icu(
611            "{n, number, currency} {d, date, short} {t, time, ::HHmm} {items, list, disjunction}",
612        )
613        .expect("parse");
614        assert!(matches!(
615            &message.nodes[0],
616            IcuNode::Number {
617                style: Some(style),
618                ..
619            } if style == "currency"
620        ));
621        assert!(matches!(
622            &message.nodes[2],
623            IcuNode::Date {
624                style: Some(style),
625                ..
626            } if style == "short"
627        ));
628        assert!(matches!(
629            &message.nodes[4],
630            IcuNode::Time {
631                style: Some(style),
632                ..
633            } if style == "::HHmm"
634        ));
635        assert!(matches!(
636            &message.nodes[6],
637            IcuNode::List {
638                style: Some(style),
639                ..
640            } if style == "disjunction"
641        ));
642    }
643
644    #[test]
645    fn parses_plural_select_and_selectordinal() {
646        let message = parse_icu(
647            "{count, plural, offset:1 =0 {none} one {# item} other {{gender, select, male {his} other {their}} items}} {rank, selectordinal, one {#st} other {#th}}",
648        )
649        .expect("parse");
650
651        assert!(matches!(
652            &message.nodes[0],
653            IcuNode::Plural {
654                kind: IcuPluralKind::Cardinal,
655                offset: 1,
656                options,
657                ..
658            } if options.len() == 3
659        ));
660        assert!(matches!(
661            &message.nodes[2],
662            IcuNode::Plural {
663                kind: IcuPluralKind::Ordinal,
664                options,
665                ..
666            } if options.len() == 2
667        ));
668    }
669
670    #[test]
671    fn parses_tags_and_nested_content() {
672        let message =
673            parse_icu("<0>{count, plural, one {<b>#</b>} other {items}}</0>").expect("parse");
674        assert!(matches!(
675            &message.nodes[0],
676            IcuNode::Tag { name, children }
677                if name == "0" && !children.is_empty()
678        ));
679    }
680
681    #[test]
682    fn parses_self_closing_tag() {
683        let message = parse_icu("Foo <0/> bar").expect("parse");
684        assert!(matches!(
685            &message.nodes[1],
686            IcuNode::Tag { name, children }
687                if name == "0" && children.is_empty()
688        ));
689    }
690
691    #[test]
692    fn self_closing_tag_round_trips() {
693        let message = parse_icu("line one<0/>line two").expect("parse");
694        assert_eq!(crate::stringify_icu(&message), "line one<0/>line two");
695    }
696
697    #[test]
698    fn parses_self_closing_tag_inside_plural_branch() {
699        let message = parse_icu("{count, plural, one {a<br/>b} other {items}}").expect("parse");
700        assert_eq!(
701            crate::stringify_icu(&message),
702            "{count, plural, one {a<br/>b} other {items}}"
703        );
704    }
705
706    #[test]
707    fn empty_tags_serialize_self_closing() {
708        // `<n/>` and the empty paired form `<n></n>` both parse to a tag with
709        // empty children and are serialized in the compact self-closing shape.
710        let paired = parse_icu("<0></0>").expect("parse paired");
711        let self_closing = parse_icu("<0/>").expect("parse self-closing");
712        assert_eq!(crate::stringify_icu(&paired), "<0/>");
713        assert_eq!(crate::stringify_icu(&self_closing), "<0/>");
714    }
715
716    #[test]
717    fn ignore_tag_treats_tags_as_literal_text() {
718        let message = parse_icu_with_options(
719            "<b>Hello</b>",
720            &IcuParserOptions::default().with_ignore_tag(true),
721        )
722        .expect("parse");
723        assert_eq!(
724            message.nodes,
725            vec![IcuNode::Literal("<b>Hello</b>".to_owned())]
726        );
727    }
728
729    #[test]
730    fn apostrophe_escaping_works() {
731        let message = parse_icu("'{'{name}'}' ''").expect("parse");
732        assert_eq!(
733            message.nodes,
734            vec![
735                IcuNode::Literal("{".to_owned()),
736                IcuNode::Argument {
737                    name: "name".to_owned()
738                },
739                IcuNode::Literal("} '".to_owned()),
740            ]
741        );
742    }
743
744    #[test]
745    fn missing_other_clause_fails_by_default() {
746        let error = parse_icu("{count, plural, one {item}}").expect_err("missing other");
747        assert!(error.message.contains("other"));
748    }
749
750    #[test]
751    fn missing_other_clause_can_be_disabled() {
752        parse_icu_with_options(
753            "{count, plural, one {item}}",
754            &IcuParserOptions::default().with_requires_other_clause(false),
755        )
756        .expect("parse");
757    }
758
759    #[test]
760    fn mismatched_closing_tag_fails() {
761        let error = parse_icu("<a>hello</b>").expect_err("mismatch");
762        assert!(error.message.contains("Mismatched"));
763    }
764
765    #[test]
766    fn invalid_offset_fails() {
767        let error = parse_icu("{count, plural, offset:x other {#}}").expect_err("invalid offset");
768        assert!(error.message.contains("integer"));
769    }
770
771    #[test]
772    fn validate_icu_uses_same_error_surface() {
773        let parse_error = parse_icu("{unclosed").expect_err("parse");
774        let validate_error = validate_icu("{unclosed").expect_err("validate");
775        assert_eq!(parse_error, validate_error);
776    }
777
778    #[test]
779    fn error_positions_are_reported() {
780        let error = parse_icu("Hello\n{unclosed").expect_err("parse");
781        assert_eq!(error.position.line, 2);
782        assert!(error.position.column >= 2);
783    }
784
785    #[test]
786    fn pound_outside_plural_is_literal() {
787        let message = parse_icu("Total # items").expect("parse");
788        assert_eq!(
789            message.nodes,
790            vec![IcuNode::Literal("Total # items".to_owned())]
791        );
792    }
793
794    #[test]
795    fn literal_stop_search_ignores_markers_after_structural_stop() {
796        assert_eq!(find_literal_stop(b"abc{later#'", true), Some(3));
797        assert_eq!(find_literal_stop(b"abc#later{", true), Some(3));
798        assert_eq!(find_literal_stop(b"abc#later{", false), Some(9));
799        assert_eq!(find_literal_stop(b"abc'later{", true), Some(3));
800    }
801
802    #[test]
803    fn parse_error_type_is_result_based() {
804        let result: Result<_, IcuParseError> = parse_icu("{broken");
805        assert!(result.is_err());
806    }
807
808    #[test]
809    fn rejects_unsupported_types_and_unexpected_trailing_input() {
810        let unsupported = parse_icu("{name, foo}").expect_err("unsupported type");
811        assert!(
812            unsupported
813                .message
814                .contains("Unsupported ICU argument type")
815        );
816
817        let trailing = parse_icu("hello}").expect_err("trailing input");
818        assert!(trailing.message.contains("Unexpected trailing input"));
819    }
820
821    #[test]
822    fn rejects_unterminated_apostrophe_and_unexpected_closing_tag() {
823        let apostrophe = parse_icu("'unterminated").expect_err("unterminated apostrophe");
824        assert!(
825            apostrophe
826                .message
827                .contains("Unterminated apostrophe escape")
828        );
829
830        let closing = parse_icu("</b>").expect_err("unexpected closing tag");
831        assert!(closing.message.contains("Unexpected closing tag"));
832    }
833
834    #[test]
835    fn parses_formatters_without_style_and_invalid_tag_names_fail() {
836        let message = parse_icu("{value, number}").expect("parse formatter without style");
837        assert!(matches!(
838            &message.nodes[0],
839            IcuNode::Number { style: None, .. }
840        ));
841
842        let error = parse_icu("<a>broken</>").expect_err("invalid closing tag");
843        assert!(error.message.contains("Mismatched closing tag"));
844    }
845}