1use crate::ast::{IcuMessage, IcuNode, IcuOption, IcuPluralKind};
2use crate::error::IcuParseError;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6#[non_exhaustive]
7pub struct IcuParserOptions {
8 pub ignore_tag: bool,
10 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 #[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 #[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
39pub fn parse_icu(input: &str) -> Result<IcuMessage, IcuParseError> {
45 parse_icu_with_options(input, &IcuParserOptions::default())
46}
47
48pub 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 literal.push('<');
129 self.pos += 1;
130 }
131 _ => {
132 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#[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 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}