1use std::collections::BTreeMap;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct IcuPlaceholder<'a> {
35 pub name: &'a str,
36 pub kind: PlaceholderKind<'a>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum PlaceholderKind<'a> {
41 Plural {
42 arms: Vec<(PluralCategory, Vec<SubSegment>)>,
43 },
44 Select {
45 arms: Vec<(&'a str, Vec<SubSegment>)>,
46 },
47 Number {
48 style: Option<NumberStyle>,
49 },
50 Date {
51 style: Option<DateStyle>,
52 },
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum FormatKind {
62 Plain,
63 Plural,
64 Select,
65 Number,
66 Date,
67}
68
69impl FormatKind {
70 pub fn as_str(&self) -> &'static str {
76 match self {
77 Self::Plain => "plain",
78 Self::Plural => "plural",
79 Self::Select => "select",
80 Self::Number => "number",
81 Self::Date => "date",
82 }
83 }
84}
85
86impl<'a> PlaceholderKind<'a> {
87 pub fn format_kind(&self) -> FormatKind {
88 match self {
89 PlaceholderKind::Plural { .. } => FormatKind::Plural,
90 PlaceholderKind::Select { .. } => FormatKind::Select,
91 PlaceholderKind::Number { .. } => FormatKind::Number,
92 PlaceholderKind::Date { .. } => FormatKind::Date,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub enum PluralCategory {
99 Zero,
100 One,
101 Two,
102 Few,
103 Many,
104 Other,
105}
106
107impl PluralCategory {
108 fn parse(s: &str) -> Option<Self> {
109 match s {
110 "zero" => Some(Self::Zero),
111 "one" => Some(Self::One),
112 "two" => Some(Self::Two),
113 "few" => Some(Self::Few),
114 "many" => Some(Self::Many),
115 "other" => Some(Self::Other),
116 _ => None,
117 }
118 }
119
120 pub fn as_str(&self) -> &'static str {
121 match self {
122 Self::Zero => "zero",
123 Self::One => "one",
124 Self::Two => "two",
125 Self::Few => "few",
126 Self::Many => "many",
127 Self::Other => "other",
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum NumberStyle {
134 Integer,
135 Percent,
136}
137
138impl NumberStyle {
139 pub fn as_str(&self) -> &'static str {
140 match self {
141 Self::Integer => "integer",
142 Self::Percent => "percent",
143 }
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum DateStyle {
149 Short,
150 Medium,
151 Long,
152 Full,
153}
154
155impl DateStyle {
156 pub fn as_str(&self) -> &'static str {
157 match self {
158 Self::Short => "short",
159 Self::Medium => "medium",
160 Self::Long => "long",
161 Self::Full => "full",
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum SubSegment {
171 Literal(String),
172 Hash,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct IcuParseError {
179 pub offset: usize,
184 pub len: usize,
185 pub kind: IcuParseErrorKind,
186}
187
188impl IcuParseError {
189 fn at(offset: usize, len: usize, kind: IcuParseErrorKind) -> Self {
190 Self {
191 offset,
192 len: len.max(1),
193 kind,
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum IcuParseErrorKind {
200 EmptyPlaceholderName,
201 MissingFormatKeyword,
202 UnknownFormatKeyword(String),
203 UnsupportedSelectordinal,
204 UnsupportedPluralOffset,
205 UnsupportedPluralExactValueArm(String),
206 UnknownStyleKeyword {
207 construct: &'static str,
208 found: String,
209 },
210 TrailingContentAfterStyle(String),
211 UnknownPluralCategory(String),
212 DuplicateArmKey(String),
213 MissingArmBody(String),
214 MissingOtherArm,
215 HashOutsidePluralArm,
216 NestedDispatchInSubMessage,
217 DanglingQuoteEscape,
218 UnbalancedArmBraces,
219}
220
221impl IcuParseErrorKind {
222 pub fn message(&self) -> String {
223 match self {
224 Self::EmptyPlaceholderName => "a message placeholder has no argument name before its first `,`".to_string(),
225 Self::MissingFormatKeyword => {
226 "a message placeholder with a `,` needs a format keyword (`plural`, `select`, `number`, or `date`) after the argument name".to_string()
227 }
228 Self::UnknownFormatKeyword(kw) => format!(
229 "unknown format keyword \"{kw}\"; expected `plural`, `select`, `number`, or `date`"
230 ),
231 Self::UnsupportedSelectordinal => {
232 "`selectordinal` is not supported; only `plural`, `select`, `number`, and `date` are".to_string()
233 }
234 Self::UnsupportedPluralOffset => {
235 "a `plural` placeholder's `offset:` is not supported".to_string()
236 }
237 Self::UnsupportedPluralExactValueArm(key) => format!(
238 "a `plural` placeholder's exact-value arm \"{key}\" is not supported; use a CLDR category (`zero`/`one`/`two`/`few`/`many`/`other`)"
239 ),
240 Self::UnknownStyleKeyword { construct, found } => format!(
241 "unknown `{construct}` style \"{found}\""
242 ),
243 Self::TrailingContentAfterStyle(found) => format!(
244 "unexpected content \"{found}\" after the style keyword"
245 ),
246 Self::UnknownPluralCategory(key) => format!(
247 "unknown plural category \"{key}\"; expected one of zero, one, two, few, many, other"
248 ),
249 Self::DuplicateArmKey(key) => format!(
250 "arm \"{key}\" is already declared in this placeholder"
251 ),
252 Self::MissingArmBody(key) => {
253 if key.is_empty() {
254 "expected an arm body (`{…}`)".to_string()
255 } else {
256 format!("arm \"{key}\" is missing its `{{…}}` body")
257 }
258 }
259 Self::MissingOtherArm => {
260 "a `plural`/`select` placeholder must declare an `other` arm".to_string()
261 }
262 Self::HashOutsidePluralArm => {
263 "`#` is only valid inside a `plural` arm's sub-message".to_string()
264 }
265 Self::NestedDispatchInSubMessage => {
266 "a sub-message cannot contain another `{…}` placeholder; only literal text and `#` are allowed"
267 .to_string()
268 }
269 Self::DanglingQuoteEscape => {
270 "an unterminated `'` quote in this template".to_string()
271 }
272 Self::UnbalancedArmBraces => "an arm's `{…}` body is never closed".to_string(),
273 }
274 }
275}
276
277fn find_top_level_comma(s: &str) -> Option<usize> {
283 let mut depth: i32 = 0;
284 let mut quoted = false;
285 let mut chars = s.char_indices().peekable();
286 while let Some((idx, ch)) = chars.next() {
287 match ch {
288 '\'' => {
289 if chars.peek().is_some_and(|&(_, c)| c == '\'') {
290 chars.next();
291 } else {
292 quoted = !quoted;
293 }
294 }
295 '{' if !quoted => depth += 1,
296 '}' if !quoted => depth -= 1,
297 ',' if !quoted && depth == 0 => return Some(idx),
298 _ => {}
299 }
300 }
301 None
302}
303
304pub fn find_icu_close(rest: &str) -> Option<usize> {
311 let mut depth: i32 = 1;
312 let mut quoted = false;
313 let mut chars = rest.char_indices().peekable();
314 while let Some((idx, ch)) = chars.next() {
315 match ch {
316 '\'' => {
317 if chars.peek().is_some_and(|&(_, c)| c == '\'') {
318 chars.next();
319 } else {
320 quoted = !quoted;
321 }
322 }
323 '{' if !quoted => depth += 1,
324 '}' if !quoted => {
325 depth -= 1;
326 if depth == 0 {
327 return Some(idx);
328 }
329 }
330 _ => {}
331 }
332 }
333 None
334}
335
336fn parse_arms(
344 text: &str,
345 base_offset: usize,
346 allow_hash: bool,
347 is_plural: bool,
348) -> Result<Vec<(String, Vec<SubSegment>)>, IcuParseError> {
349 let mut arms = Vec::new();
350 let mut i = 0usize;
351 loop {
352 while i < text.len() && text.as_bytes()[i].is_ascii_whitespace() {
353 i += 1;
354 }
355 if i >= text.len() {
356 break;
357 }
358 let key_start = i;
359 while i < text.len() {
360 let c = text[i..].chars().next().expect("i < text.len()");
361 if c.is_whitespace() || c == '{' {
362 break;
363 }
364 i += c.len_utf8();
365 }
366 let key = &text[key_start..i];
367 if key.is_empty() {
368 return Err(IcuParseError::at(
369 base_offset + i,
370 1,
371 IcuParseErrorKind::MissingArmBody(String::new()),
372 ));
373 }
374 if is_plural && key.starts_with("offset:") {
375 return Err(IcuParseError::at(
376 base_offset + key_start,
377 key.len(),
378 IcuParseErrorKind::UnsupportedPluralOffset,
379 ));
380 }
381 if is_plural && key.starts_with('=') {
382 return Err(IcuParseError::at(
383 base_offset + key_start,
384 key.len(),
385 IcuParseErrorKind::UnsupportedPluralExactValueArm(key.to_string()),
386 ));
387 }
388 if is_plural && PluralCategory::parse(key).is_none() {
389 return Err(IcuParseError::at(
390 base_offset + key_start,
391 key.len(),
392 IcuParseErrorKind::UnknownPluralCategory(key.to_string()),
393 ));
394 }
395 if arms.iter().any(|(k, _)| k == key) {
402 return Err(IcuParseError::at(
403 base_offset + key_start,
404 key.len(),
405 IcuParseErrorKind::DuplicateArmKey(key.to_string()),
406 ));
407 }
408 while i < text.len() && text.as_bytes()[i].is_ascii_whitespace() {
409 i += 1;
410 }
411 if i >= text.len() || text.as_bytes()[i] != b'{' {
412 return Err(IcuParseError::at(
413 base_offset + i,
414 1,
415 IcuParseErrorKind::MissingArmBody(key.to_string()),
416 ));
417 }
418 i += 1; let body_start = i;
420 let (segs, consumed) =
421 parse_sub_message(&text[body_start..], base_offset + body_start, allow_hash)?;
422 arms.push((key.to_string(), segs));
423 i = body_start + consumed;
424 }
425 Ok(arms)
426}
427
428fn parse_sub_message(
433 s: &str,
434 base_offset: usize,
435 allow_hash: bool,
436) -> Result<(Vec<SubSegment>, usize), IcuParseError> {
437 let mut segs = Vec::new();
438 let mut literal = String::new();
439 let mut quoted = false;
440 let mut chars = s.char_indices().peekable();
441 while let Some((idx, ch)) = chars.next() {
442 match ch {
443 '\'' => {
444 if chars.peek().is_some_and(|&(_, c)| c == '\'') {
445 chars.next();
446 literal.push('\'');
447 } else {
448 quoted = !quoted;
449 }
450 }
451 '{' if !quoted => {
452 return Err(IcuParseError::at(
453 base_offset + idx,
454 1,
455 IcuParseErrorKind::NestedDispatchInSubMessage,
456 ));
457 }
458 '}' if !quoted => {
459 if !literal.is_empty() {
460 segs.push(SubSegment::Literal(std::mem::take(&mut literal)));
461 }
462 return Ok((segs, idx + 1));
463 }
464 '#' if !quoted && allow_hash => {
465 if !literal.is_empty() {
466 segs.push(SubSegment::Literal(std::mem::take(&mut literal)));
467 }
468 segs.push(SubSegment::Hash);
469 }
470 '#' if !quoted => {
471 return Err(IcuParseError::at(
472 base_offset + idx,
473 1,
474 IcuParseErrorKind::HashOutsidePluralArm,
475 ));
476 }
477 other => literal.push(other),
478 }
479 }
480 if quoted {
481 Err(IcuParseError::at(
482 base_offset + s.len(),
483 1,
484 IcuParseErrorKind::DanglingQuoteEscape,
485 ))
486 } else {
487 Err(IcuParseError::at(
488 base_offset,
489 s.len().max(1),
490 IcuParseErrorKind::UnbalancedArmBraces,
491 ))
492 }
493}
494
495fn ensure_other_present(
496 arms: &[(String, Vec<SubSegment>)],
497 base_offset: usize,
498 whole_len: usize,
499) -> Result<(), IcuParseError> {
500 if arms.iter().any(|(k, _)| k == "other") {
501 Ok(())
502 } else {
503 Err(IcuParseError::at(
504 base_offset,
505 whole_len,
506 IcuParseErrorKind::MissingOtherArm,
507 ))
508 }
509}
510
511pub fn parse_icu_placeholder(inner: &str) -> Result<IcuPlaceholder<'_>, IcuParseError> {
517 let name_end = find_top_level_comma(inner).ok_or_else(|| {
518 IcuParseError::at(0, inner.len(), IcuParseErrorKind::MissingFormatKeyword)
519 })?;
520 let name = inner[..name_end].trim();
521 if name.is_empty() {
522 return Err(IcuParseError::at(
523 0,
524 name_end,
525 IcuParseErrorKind::EmptyPlaceholderName,
526 ));
527 }
528 let after_name = &inner[name_end + 1..];
529 let after_name_offset = name_end + 1;
530 let keyword_end = find_top_level_comma(after_name);
531 let (keyword_text, arms_or_style, arms_or_style_offset) = match keyword_end {
532 Some(k) => (
533 &after_name[..k],
534 Some(&after_name[k + 1..]),
535 after_name_offset + k + 1,
536 ),
537 None => (after_name, None, after_name_offset + after_name.len()),
538 };
539 let keyword = keyword_text.trim();
540 let keyword_offset = after_name_offset;
541
542 match keyword {
543 "plural" | "select" => {
544 let is_plural = keyword == "plural";
545 let (arms_text, arms_offset) = arms_or_style
546 .map(|a| (a, arms_or_style_offset))
547 .ok_or_else(|| {
548 IcuParseError::at(
549 after_name_offset,
550 after_name.len(),
551 IcuParseErrorKind::MissingArmBody(String::new()),
552 )
553 })?;
554 let raw_arms = parse_arms(arms_text, arms_offset, is_plural, is_plural)?;
555 ensure_other_present(&raw_arms, arms_offset, arms_text.len())?;
556 if is_plural {
557 let arms = raw_arms
558 .into_iter()
559 .map(|(k, segs)| {
560 (
561 PluralCategory::parse(&k).expect("validated by parse_arms"),
562 segs,
563 )
564 })
565 .collect();
566 Ok(IcuPlaceholder {
567 name,
568 kind: PlaceholderKind::Plural { arms },
569 })
570 } else {
571 let arms = raw_arms
572 .into_iter()
573 .map(|(k, segs)| (inner_arm_key_slice(inner, arms_offset, &k), segs))
574 .collect();
575 Ok(IcuPlaceholder {
576 name,
577 kind: PlaceholderKind::Select { arms },
578 })
579 }
580 }
581 "number" => {
582 let style =
583 parse_optional_style(arms_or_style, arms_or_style_offset, "number", |s| match s {
584 "integer" => Some(NumberStyle::Integer),
585 "percent" => Some(NumberStyle::Percent),
586 _ => None,
587 })?;
588 Ok(IcuPlaceholder {
589 name,
590 kind: PlaceholderKind::Number { style },
591 })
592 }
593 "date" => {
594 let style =
595 parse_optional_style(arms_or_style, arms_or_style_offset, "date", |s| match s {
596 "short" => Some(DateStyle::Short),
597 "medium" => Some(DateStyle::Medium),
598 "long" => Some(DateStyle::Long),
599 "full" => Some(DateStyle::Full),
600 _ => None,
601 })?;
602 Ok(IcuPlaceholder {
603 name,
604 kind: PlaceholderKind::Date { style },
605 })
606 }
607 "selectordinal" => Err(IcuParseError::at(
608 keyword_offset,
609 keyword_text.len(),
610 IcuParseErrorKind::UnsupportedSelectordinal,
611 )),
612 "" => Err(IcuParseError::at(
613 after_name_offset,
614 1,
615 IcuParseErrorKind::MissingFormatKeyword,
616 )),
617 other => Err(IcuParseError::at(
618 keyword_offset,
619 keyword_text.len(),
620 IcuParseErrorKind::UnknownFormatKeyword(other.to_string()),
621 )),
622 }
623}
624
625fn inner_arm_key_slice<'a>(inner: &'a str, _arms_offset: usize, k: &str) -> &'a str {
633 let start = inner
639 .find(k)
640 .expect("arm key is a verbatim substring of `inner`");
641 &inner[start..start + k.len()]
642}
643
644fn parse_optional_style<T>(
645 arms_or_style: Option<&str>,
646 arms_or_style_offset: usize,
647 construct: &'static str,
648 parse: impl Fn(&str) -> Option<T>,
649) -> Result<Option<T>, IcuParseError> {
650 let Some(s) = arms_or_style else {
651 return Ok(None);
652 };
653 if let Some(extra_comma) = find_top_level_comma(s) {
654 return Err(IcuParseError::at(
655 arms_or_style_offset + extra_comma + 1,
656 s.len().saturating_sub(extra_comma + 1).max(1),
657 IcuParseErrorKind::TrailingContentAfterStyle(s[extra_comma + 1..].trim().to_string()),
658 ));
659 }
660 let trimmed = s.trim();
661 parse(trimmed).map(Some).ok_or_else(|| {
662 IcuParseError::at(
663 arms_or_style_offset,
664 s.len(),
665 IcuParseErrorKind::UnknownStyleKeyword {
666 construct,
667 found: trimmed.to_string(),
668 },
669 )
670 })
671}
672
673pub enum TemplateSegment<'a> {
682 Literal(&'a str),
683 Placeholder { offset: usize, inner: &'a str },
684}
685
686pub fn split_template(template: &str) -> Vec<TemplateSegment<'_>> {
704 let mut segments = Vec::new();
705 let mut literal_start = 0;
706 let mut i = 0;
707 while i < template.len() {
708 if template.as_bytes()[i] == b'{' {
709 let rest = &template[i + 1..];
710 let first_close = rest.find('}');
711 let first_comma = rest.find(',');
712 let is_icu = match (first_comma, first_close) {
713 (Some(c), Some(cl)) => c < cl,
714 (Some(_), None) => true,
715 (None, _) => false,
716 };
717 if is_icu {
718 if let Some(close_rel) = find_icu_close(rest) {
719 let inner = &rest[..close_rel];
720 if literal_start < i {
721 segments.push(TemplateSegment::Literal(&template[literal_start..i]));
722 }
723 segments.push(TemplateSegment::Placeholder {
724 offset: i + 1,
725 inner,
726 });
727 i = i + 1 + close_rel + 1;
728 literal_start = i;
729 continue;
730 }
731 } else if let Some(rel_end) = first_close {
734 let name = &rest[..rel_end];
735 if !name.is_empty() && !name.contains('{') {
736 if literal_start < i {
737 segments.push(TemplateSegment::Literal(&template[literal_start..i]));
738 }
739 segments.push(TemplateSegment::Placeholder {
740 offset: i + 1,
741 inner: name,
742 });
743 i = i + 1 + rel_end + 1;
744 literal_start = i;
745 continue;
746 }
747 }
748 }
749 i += template[i..]
751 .chars()
752 .next()
753 .map(char::len_utf8)
754 .unwrap_or(1);
755 }
756 if literal_start < template.len() || segments.is_empty() {
757 segments.push(TemplateSegment::Literal(&template[literal_start..]));
758 }
759 segments
760}
761
762pub fn placeholder_names(template: &str) -> std::collections::BTreeSet<&str> {
772 split_template(template)
773 .into_iter()
774 .filter_map(|s| match s {
775 TemplateSegment::Placeholder { inner, .. } => Some(match inner.find(',') {
776 None => inner,
777 Some(idx) => inner[..idx].trim(),
778 }),
779 TemplateSegment::Literal(_) => None,
780 })
781 .collect()
782}
783
784pub fn icu_dispatch_placeholders(template: &str) -> Vec<(usize, &str)> {
791 split_template(template)
792 .into_iter()
793 .filter_map(|s| match s {
794 TemplateSegment::Placeholder { offset, inner } if inner.contains(',') => {
795 Some((offset, inner))
796 }
797 _ => None,
798 })
799 .collect()
800}
801
802pub fn template_format_kinds(template: &str) -> BTreeMap<&str, FormatKind> {
808 split_template(template)
809 .into_iter()
810 .filter_map(|s| match s {
811 TemplateSegment::Placeholder { inner, .. } => {
812 if let Some(comma) = inner.find(',') {
813 let name = inner[..comma].trim();
814 parse_icu_placeholder(inner)
815 .ok()
816 .map(|p| (name, p.kind.format_kind()))
817 } else {
818 Some((inner, FormatKind::Plain))
819 }
820 }
821 _ => None,
822 })
823 .collect()
824}
825
826#[cfg(test)]
827mod icu_parser_tests {
828 use super::*;
829
830 fn parse(s: &str) -> IcuPlaceholder<'_> {
831 parse_icu_placeholder(s).unwrap_or_else(|e| panic!("expected Ok, got {e:?} for {s:?}"))
832 }
833
834 fn parse_err(s: &str) -> IcuParseErrorKind {
835 parse_icu_placeholder(s)
836 .expect_err("expected an error")
837 .kind
838 }
839
840 #[test]
841 fn plural_all_six_categories() {
842 let p = parse(
843 "n, plural, zero {none} one {#1} two {#2} few {#few} many {#many} other {#other}",
844 );
845 assert_eq!(p.name, "n");
846 let PlaceholderKind::Plural { arms } = p.kind else {
847 panic!("expected Plural")
848 };
849 let cats: Vec<_> = arms.iter().map(|(c, _)| *c).collect();
850 assert_eq!(
851 cats,
852 vec![
853 PluralCategory::Zero,
854 PluralCategory::One,
855 PluralCategory::Two,
856 PluralCategory::Few,
857 PluralCategory::Many,
858 PluralCategory::Other,
859 ]
860 );
861 }
862
863 #[test]
864 fn plural_hash_and_literal_segments() {
865 let p = parse("n, plural, one {# item} other {# items}");
866 let PlaceholderKind::Plural { arms } = p.kind else {
867 panic!("expected Plural")
868 };
869 assert_eq!(
870 arms[0].1,
871 vec![SubSegment::Hash, SubSegment::Literal(" item".to_string())]
872 );
873 assert_eq!(
874 arms[1].1,
875 vec![SubSegment::Hash, SubSegment::Literal(" items".to_string())]
876 );
877 }
878
879 #[test]
880 fn select_arbitrary_keys() {
881 let p = parse("g, select, male {He} female {She} other {They}");
882 assert_eq!(p.name, "g");
883 let PlaceholderKind::Select { arms } = p.kind else {
884 panic!("expected Select")
885 };
886 assert_eq!(arms[0].0, "male");
887 assert_eq!(arms[2].0, "other");
888 }
889
890 #[test]
891 fn number_bare_integer_percent() {
892 assert_eq!(
893 parse("n, number").kind,
894 PlaceholderKind::Number { style: None }
895 );
896 assert_eq!(
897 parse("n, number, integer").kind,
898 PlaceholderKind::Number {
899 style: Some(NumberStyle::Integer)
900 }
901 );
902 assert_eq!(
903 parse("n, number, percent").kind,
904 PlaceholderKind::Number {
905 style: Some(NumberStyle::Percent)
906 }
907 );
908 }
909
910 #[test]
911 fn date_bare_and_all_four_styles() {
912 assert_eq!(parse("d, date").kind, PlaceholderKind::Date { style: None });
913 for (kw, expect) in [
914 ("short", DateStyle::Short),
915 ("medium", DateStyle::Medium),
916 ("long", DateStyle::Long),
917 ("full", DateStyle::Full),
918 ] {
919 assert_eq!(
920 parse(&format!("d, date, {kw}")).kind,
921 PlaceholderKind::Date {
922 style: Some(expect)
923 }
924 );
925 }
926 }
927
928 #[test]
929 fn quoting_doubled_quote_is_literal_apostrophe() {
930 let p = parse("n, plural, one {it''s one} other {it''s other}");
931 let PlaceholderKind::Plural { arms } = p.kind else {
932 panic!("expected Plural")
933 };
934 assert_eq!(arms[0].1, vec![SubSegment::Literal("it's one".to_string())]);
935 }
936
937 #[test]
938 fn quoting_escapes_literal_brace() {
939 let p = parse("n, plural, one {'{'} other {ok}");
940 let PlaceholderKind::Plural { arms } = p.kind else {
941 panic!("expected Plural")
942 };
943 assert_eq!(arms[0].1, vec![SubSegment::Literal("{".to_string())]);
944 }
945
946 #[test]
947 fn quoting_escapes_literal_hash() {
948 let p = parse("n, plural, one {'#'} other {ok}");
949 let PlaceholderKind::Plural { arms } = p.kind else {
950 panic!("expected Plural")
951 };
952 assert_eq!(arms[0].1, vec![SubSegment::Literal("#".to_string())]);
953 }
954
955 #[test]
956 fn multibyte_arm_body() {
957 let p = parse("n, select, other {caf\u{e9} \u{1f980}}");
958 let PlaceholderKind::Select { arms } = p.kind else {
959 panic!("expected Select")
960 };
961 assert_eq!(
962 arms[0].1,
963 vec![SubSegment::Literal("caf\u{e9} \u{1f980}".to_string())]
964 );
965 }
966
967 #[test]
968 fn err_empty_name() {
969 assert_eq!(
970 parse_err(", plural, other {x}"),
971 IcuParseErrorKind::EmptyPlaceholderName
972 );
973 }
974
975 #[test]
976 fn err_missing_format_keyword_no_second_comma() {
977 assert_eq!(parse_err("n"), IcuParseErrorKind::MissingFormatKeyword);
978 }
979
980 #[test]
981 fn err_unknown_format_keyword() {
982 assert_eq!(
983 parse_err("d, duration"),
984 IcuParseErrorKind::UnknownFormatKeyword("duration".to_string())
985 );
986 }
987
988 #[test]
989 fn err_selectordinal_unsupported() {
990 assert_eq!(
991 parse_err("rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}"),
992 IcuParseErrorKind::UnsupportedSelectordinal
993 );
994 }
995
996 #[test]
997 fn err_plural_offset_unsupported() {
998 assert_eq!(
999 parse_err("n, plural, offset:1 one {#} other {#}"),
1000 IcuParseErrorKind::UnsupportedPluralOffset
1001 );
1002 }
1003
1004 #[test]
1005 fn err_plural_exact_value_arm_unsupported() {
1006 assert_eq!(
1007 parse_err("n, plural, =0 {none} one {#} other {#}"),
1008 IcuParseErrorKind::UnsupportedPluralExactValueArm("=0".to_string())
1009 );
1010 }
1011
1012 #[test]
1013 fn err_unknown_plural_category() {
1014 assert_eq!(
1015 parse_err("n, plural, teen {x} other {y}"),
1016 IcuParseErrorKind::UnknownPluralCategory("teen".to_string())
1017 );
1018 }
1019
1020 #[test]
1021 fn err_duplicate_plural_arm() {
1022 assert_eq!(
1026 parse_err("n, plural, one {a} one {b} other {c}"),
1027 IcuParseErrorKind::DuplicateArmKey("one".to_string())
1028 );
1029 }
1030
1031 #[test]
1032 fn err_duplicate_select_arm() {
1033 assert_eq!(
1034 parse_err("g, select, male {a} male {b} other {c}"),
1035 IcuParseErrorKind::DuplicateArmKey("male".to_string())
1036 );
1037 }
1038
1039 #[test]
1040 fn err_missing_other_arm() {
1041 assert_eq!(
1042 parse_err("n, plural, one {# item}"),
1043 IcuParseErrorKind::MissingOtherArm
1044 );
1045 }
1046
1047 #[test]
1048 fn err_hash_outside_plural_arm() {
1049 assert_eq!(
1050 parse_err("g, select, male {# things} female {things} other {things}"),
1051 IcuParseErrorKind::HashOutsidePluralArm
1052 );
1053 }
1054
1055 #[test]
1056 fn err_nested_dispatch_in_sub_message() {
1057 assert_eq!(
1058 parse_err("n, plural, one {nested {m, number}} other {ok}"),
1059 IcuParseErrorKind::NestedDispatchInSubMessage
1060 );
1061 }
1062
1063 #[test]
1064 fn err_unbalanced_arm_braces() {
1065 assert_eq!(
1069 parse_err("n, plural, one {# item"),
1070 IcuParseErrorKind::UnbalancedArmBraces
1071 );
1072 }
1073
1074 #[test]
1075 fn err_unknown_number_style() {
1076 assert_eq!(
1077 parse_err("n, number, currency"),
1078 IcuParseErrorKind::UnknownStyleKeyword {
1079 construct: "number",
1080 found: "currency".to_string(),
1081 }
1082 );
1083 }
1084
1085 #[test]
1086 fn err_unknown_date_style() {
1087 assert_eq!(
1088 parse_err("d, date, yyyy"),
1089 IcuParseErrorKind::UnknownStyleKeyword {
1090 construct: "date",
1091 found: "yyyy".to_string(),
1092 }
1093 );
1094 }
1095
1096 #[test]
1097 fn err_trailing_content_after_style() {
1098 assert_eq!(
1099 parse_err("n, number, integer, extra"),
1100 IcuParseErrorKind::TrailingContentAfterStyle("extra".to_string())
1101 );
1102 }
1103
1104 #[test]
1105 fn find_icu_close_handles_nested_arms_and_quoting() {
1106 let rest = "count, plural, one {'{'} other {#}} tail";
1107 let close = find_icu_close(rest).expect("should close");
1108 assert_eq!(&rest[..close], "count, plural, one {'{'} other {#}");
1109 }
1110
1111 #[test]
1112 fn template_format_kinds_mixed() {
1113 let kinds = template_format_kinds(
1114 "{a} {b, plural, one {#} other {#}} {c, select, x {x} other {o}} {d, number} {e, date}",
1115 );
1116 assert_eq!(kinds.get("a"), Some(&FormatKind::Plain));
1117 assert_eq!(kinds.get("b"), Some(&FormatKind::Plural));
1118 assert_eq!(kinds.get("c"), Some(&FormatKind::Select));
1119 assert_eq!(kinds.get("d"), Some(&FormatKind::Number));
1120 assert_eq!(kinds.get("e"), Some(&FormatKind::Date));
1121 }
1122
1123 #[test]
1124 fn icu_dispatch_placeholders_skips_plain_ones() {
1125 let found = icu_dispatch_placeholders("hi {name}, you have {n, plural, one {#} other {#}}");
1126 assert_eq!(found.len(), 1);
1127 assert!(found[0].1.starts_with("n, plural"));
1128 }
1129}