1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fmt,
4};
5
6use crate::ast::{IcuMessage, IcuNode, IcuOption, IcuPluralKind};
7use crate::diagnostic_codes::{self, DiagnosticCode};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum IcuDiagnosticSeverity {
12 Info,
14 Warning,
16 Error,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22#[non_exhaustive]
23pub enum IcuArgumentKind {
24 Argument,
26 Number,
28 Date,
30 Time,
32 List,
34 Duration,
36 Ago,
38 Name,
40 Select,
42 Plural,
44 SelectOrdinal,
46}
47
48impl fmt::Display for IcuArgumentKind {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 formatter.write_str(match self {
51 Self::Argument => "argument",
52 Self::Number => "number",
53 Self::Date => "date",
54 Self::Time => "time",
55 Self::List => "list",
56 Self::Duration => "duration",
57 Self::Ago => "ago",
58 Self::Name => "name",
59 Self::Select => "select",
60 Self::Plural => "plural",
61 Self::SelectOrdinal => "selectordinal",
62 })
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum IcuStyleKind {
70 None,
72 Predefined,
74 Skeleton,
76 Pattern,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct IcuArgument {
83 pub name: String,
85 pub kind: IcuArgumentKind,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct IcuFormatter {
92 pub name: String,
94 pub kind: IcuArgumentKind,
96 pub style: Option<String>,
98 pub style_kind: IcuStyleKind,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum IcuFormatterSupport {
106 Supported,
108 UnsupportedKind {
113 severity: IcuDiagnosticSeverity,
115 },
116 UnsupportedStyle {
122 severity: IcuDiagnosticSeverity,
124 },
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct IcuSelectSummary {
130 pub name: String,
132 pub selectors: Vec<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct IcuPluralSummary {
139 pub name: String,
141 pub kind: IcuPluralKind,
143 pub offset: u32,
145 pub selectors: Vec<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct IcuTagSummary {
152 pub name: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Default)]
158pub struct IcuAnalysis {
159 pub arguments: Vec<IcuArgument>,
161 pub formatters: Vec<IcuFormatter>,
163 pub plurals: Vec<IcuPluralSummary>,
165 pub selects: Vec<IcuSelectSummary>,
167 pub tags: Vec<IcuTagSummary>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub struct IcuCompatibilityOptions {
175 pub report_extra_arguments: bool,
177 pub report_extra_tags: bool,
179 pub report_extra_selectors: bool,
181 pub report_pattern_styles: bool,
183}
184
185impl Default for IcuCompatibilityOptions {
186 fn default() -> Self {
187 Self {
188 report_extra_arguments: true,
189 report_extra_tags: true,
190 report_extra_selectors: true,
191 report_pattern_styles: true,
192 }
193 }
194}
195
196impl IcuCompatibilityOptions {
197 #[must_use]
199 pub fn with_report_extra_arguments(mut self, report_extra_arguments: bool) -> Self {
200 self.report_extra_arguments = report_extra_arguments;
201 self
202 }
203
204 #[must_use]
206 pub fn with_report_extra_tags(mut self, report_extra_tags: bool) -> Self {
207 self.report_extra_tags = report_extra_tags;
208 self
209 }
210
211 #[must_use]
213 pub fn with_report_extra_selectors(mut self, report_extra_selectors: bool) -> Self {
214 self.report_extra_selectors = report_extra_selectors;
215 self
216 }
217
218 #[must_use]
220 pub fn with_report_pattern_styles(mut self, report_pattern_styles: bool) -> Self {
221 self.report_pattern_styles = report_pattern_styles;
222 self
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct IcuDiagnostic {
229 pub severity: IcuDiagnosticSeverity,
231 pub code: DiagnosticCode,
233 pub message: String,
235 pub name: Option<String>,
237}
238
239impl IcuDiagnostic {
240 fn new(
241 severity: IcuDiagnosticSeverity,
242 code: impl Into<DiagnosticCode>,
243 message: impl Into<String>,
244 name: impl Into<Option<String>>,
245 ) -> Self {
246 Self {
247 severity,
248 code: code.into(),
249 message: message.into(),
250 name: name.into(),
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Default)]
257pub struct IcuCompatibilityReport {
258 pub diagnostics: Vec<IcuDiagnostic>,
260}
261
262impl IcuCompatibilityReport {
263 #[must_use]
265 pub fn has_errors(&self) -> bool {
266 self.diagnostics
267 .iter()
268 .any(|diagnostic| diagnostic.severity == IcuDiagnosticSeverity::Error)
269 }
270}
271
272#[must_use]
301pub fn analyze_icu(message: &IcuMessage) -> IcuAnalysis {
302 let mut analysis = IcuAnalysis::default();
303 visit_nodes(&message.nodes, &mut analysis);
304 analysis
305}
306
307#[must_use]
309pub fn extract_argument_names(message: &IcuMessage) -> Vec<String> {
310 unique_names(analyze_icu(message).arguments.iter().map(|arg| &arg.name))
311}
312
313#[must_use]
315pub fn extract_tag_names(message: &IcuMessage) -> Vec<String> {
316 unique_names(analyze_icu(message).tags.iter().map(|tag| &tag.name))
317}
318
319#[must_use]
326pub fn validate_icu_formatter_support(
327 message: &IcuMessage,
328 support: impl FnMut(&IcuFormatter) -> IcuFormatterSupport,
329) -> IcuCompatibilityReport {
330 let analysis = analyze_icu(message);
331 validate_icu_formatter_support_from_analysis(&analysis, support)
332}
333
334#[must_use]
341pub fn validate_icu_formatter_support_from_analysis(
342 analysis: &IcuAnalysis,
343 mut support: impl FnMut(&IcuFormatter) -> IcuFormatterSupport,
344) -> IcuCompatibilityReport {
345 let mut report = IcuCompatibilityReport::default();
346
347 for formatter in &analysis.formatters {
348 match support(formatter) {
349 IcuFormatterSupport::Supported => {}
350 IcuFormatterSupport::UnsupportedKind { severity } => {
351 report.diagnostics.push(IcuDiagnostic::new(
352 severity,
353 diagnostic_codes::icu::UNSUPPORTED_FORMATTER_KIND,
354 format!(
355 "ICU formatter `{}` uses unsupported formatter kind `{}`.",
356 formatter.name, formatter.kind
357 ),
358 Some(formatter.name.clone()),
359 ));
360 }
361 IcuFormatterSupport::UnsupportedStyle { severity } => {
362 report.diagnostics.push(IcuDiagnostic::new(
363 severity,
364 diagnostic_codes::icu::UNSUPPORTED_FORMATTER_STYLE,
365 format!(
366 "ICU formatter `{}` uses unsupported `{}` formatter style `{}`.",
367 formatter.name,
368 formatter.kind,
369 formatter_style_label(formatter.style.as_deref())
370 ),
371 Some(formatter.name.clone()),
372 ));
373 }
374 }
375 }
376
377 report
378}
379
380#[must_use]
405pub fn compare_icu_messages(
406 source: &IcuMessage,
407 translation: &IcuMessage,
408 options: &IcuCompatibilityOptions,
409) -> IcuCompatibilityReport {
410 let source = analyze_icu(source);
411 let translation = analyze_icu(translation);
412 let mut report = IcuCompatibilityReport::default();
413
414 compare_arguments(&source, &translation, options, &mut report);
415 compare_formatter_styles(&source, &translation, &mut report);
416 compare_tags(&source, &translation, options, &mut report);
417 compare_selects(&source, &translation, options, &mut report);
418 compare_plurals(&source, &translation, &mut report);
419 if options.report_pattern_styles {
420 report_pattern_styles(&source, "source", &mut report);
421 report_pattern_styles(&translation, "translation", &mut report);
422 }
423
424 report
425}
426
427fn visit_nodes(nodes: &[IcuNode], analysis: &mut IcuAnalysis) {
428 for node in nodes {
429 match node {
430 IcuNode::Literal(_) | IcuNode::Pound => {}
431 IcuNode::Argument { name } => {
432 push_argument(analysis, name, IcuArgumentKind::Argument);
433 }
434 IcuNode::Number { name, style } => {
435 push_formatter(analysis, name, IcuArgumentKind::Number, style);
436 }
437 IcuNode::Date { name, style } => {
438 push_formatter(analysis, name, IcuArgumentKind::Date, style);
439 }
440 IcuNode::Time { name, style } => {
441 push_formatter(analysis, name, IcuArgumentKind::Time, style);
442 }
443 IcuNode::List { name, style } => {
444 push_formatter(analysis, name, IcuArgumentKind::List, style);
445 }
446 IcuNode::Duration { name, style } => {
447 push_formatter(analysis, name, IcuArgumentKind::Duration, style);
448 }
449 IcuNode::Ago { name, style } => {
450 push_formatter(analysis, name, IcuArgumentKind::Ago, style);
451 }
452 IcuNode::Name { name, style } => {
453 push_formatter(analysis, name, IcuArgumentKind::Name, style);
454 }
455 IcuNode::Select { name, options } => {
456 push_argument(analysis, name, IcuArgumentKind::Select);
457 analysis.selects.push(IcuSelectSummary {
458 name: name.clone(),
459 selectors: selectors(options),
460 });
461 visit_options(options, analysis);
462 }
463 IcuNode::Plural {
464 name,
465 kind,
466 offset,
467 options,
468 } => {
469 let argument_kind = match kind {
470 IcuPluralKind::Cardinal => IcuArgumentKind::Plural,
471 IcuPluralKind::Ordinal => IcuArgumentKind::SelectOrdinal,
472 };
473 push_argument(analysis, name, argument_kind);
474 analysis.plurals.push(IcuPluralSummary {
475 name: name.clone(),
476 kind: kind.clone(),
477 offset: *offset,
478 selectors: selectors(options),
479 });
480 visit_options(options, analysis);
481 }
482 IcuNode::Tag { name, children, .. } => {
483 analysis.tags.push(IcuTagSummary { name: name.clone() });
484 visit_nodes(children, analysis);
485 }
486 }
487 }
488}
489
490fn visit_options(options: &[IcuOption], analysis: &mut IcuAnalysis) {
491 for option in options {
492 visit_nodes(&option.value, analysis);
493 }
494}
495
496fn push_argument(analysis: &mut IcuAnalysis, name: &str, kind: IcuArgumentKind) {
497 analysis.arguments.push(IcuArgument {
498 name: name.to_owned(),
499 kind,
500 });
501}
502
503fn push_formatter(
504 analysis: &mut IcuAnalysis,
505 name: &str,
506 kind: IcuArgumentKind,
507 style: &Option<String>,
508) {
509 push_argument(analysis, name, kind);
510 analysis.formatters.push(IcuFormatter {
511 name: name.to_owned(),
512 kind,
513 style: style.clone(),
514 style_kind: classify_style(kind, style.as_deref()),
515 });
516}
517
518fn selectors(options: &[IcuOption]) -> Vec<String> {
519 options
520 .iter()
521 .map(|option| option.selector.clone())
522 .collect()
523}
524
525fn classify_style(kind: IcuArgumentKind, style: Option<&str>) -> IcuStyleKind {
526 let Some(style) = style.map(str::trim).filter(|style| !style.is_empty()) else {
527 return IcuStyleKind::None;
528 };
529 if style.starts_with("::") {
530 return IcuStyleKind::Skeleton;
531 }
532 if is_predefined_style(kind, style) {
533 return IcuStyleKind::Predefined;
534 }
535 IcuStyleKind::Pattern
536}
537
538fn is_predefined_style(kind: IcuArgumentKind, style: &str) -> bool {
539 match kind {
540 IcuArgumentKind::Number => matches!(style, "integer" | "currency" | "percent"),
541 IcuArgumentKind::Date | IcuArgumentKind::Time => {
542 matches!(style, "short" | "medium" | "long" | "full")
543 }
544 IcuArgumentKind::List => matches!(style, "conjunction" | "disjunction" | "unit"),
545 _ => false,
546 }
547}
548
549fn unique_names<'a>(names: impl IntoIterator<Item = &'a String>) -> Vec<String> {
550 let mut seen = BTreeSet::new();
551 let mut out = Vec::new();
552 for name in names {
553 if seen.insert(name.as_str()) {
554 out.push(name.clone());
555 }
556 }
557 out
558}
559
560fn argument_map(analysis: &IcuAnalysis) -> BTreeMap<&str, IcuArgumentKind> {
561 analysis
562 .arguments
563 .iter()
564 .map(|argument| (argument.name.as_str(), argument.kind))
565 .collect()
566}
567
568fn formatter_map(analysis: &IcuAnalysis) -> BTreeMap<(&str, IcuArgumentKind), Option<&str>> {
569 analysis
570 .formatters
571 .iter()
572 .map(|formatter| {
573 (
574 (formatter.name.as_str(), formatter.kind),
575 formatter.style.as_deref().map(str::trim),
576 )
577 })
578 .collect()
579}
580
581fn selector_set(selectors: &[String]) -> BTreeSet<&str> {
582 selectors.iter().map(String::as_str).collect()
583}
584
585fn formatter_style_label(style: Option<&str>) -> &str {
586 style
587 .map(str::trim)
588 .filter(|style| !style.is_empty())
589 .unwrap_or("<none>")
590}
591
592fn compare_arguments(
593 source: &IcuAnalysis,
594 translation: &IcuAnalysis,
595 options: &IcuCompatibilityOptions,
596 report: &mut IcuCompatibilityReport,
597) {
598 let source_arguments = argument_map(source);
599 let translation_arguments = argument_map(translation);
600
601 for (name, source_kind) in &source_arguments {
602 let Some(translation_kind) = translation_arguments.get(name) else {
603 report.diagnostics.push(IcuDiagnostic::new(
604 IcuDiagnosticSeverity::Error,
605 diagnostic_codes::icu::MISSING_ARGUMENT,
606 format!("Translation is missing ICU argument `{name}`."),
607 Some((*name).to_owned()),
608 ));
609 continue;
610 };
611 if source_kind != translation_kind {
612 report.diagnostics.push(IcuDiagnostic::new(
613 IcuDiagnosticSeverity::Error,
614 diagnostic_codes::icu::ARGUMENT_KIND_CHANGED,
615 format!(
616 "Translation changes ICU argument `{name}` from {source_kind:?} to {translation_kind:?}."
617 ),
618 Some((*name).to_owned()),
619 ));
620 }
621 }
622
623 if options.report_extra_arguments {
624 for name in translation_arguments.keys() {
625 if !source_arguments.contains_key(name) {
626 report.diagnostics.push(IcuDiagnostic::new(
627 IcuDiagnosticSeverity::Error,
628 diagnostic_codes::icu::EXTRA_ARGUMENT,
629 format!(
630 "Translation adds ICU argument `{name}` that is not present in source."
631 ),
632 Some((*name).to_owned()),
633 ));
634 }
635 }
636 }
637}
638
639fn compare_formatter_styles(
640 source: &IcuAnalysis,
641 translation: &IcuAnalysis,
642 report: &mut IcuCompatibilityReport,
643) {
644 let source_formatters = formatter_map(source);
645 let translation_formatters = formatter_map(translation);
646
647 for ((name, kind), source_style) in source_formatters {
648 let Some(translation_style) = translation_formatters.get(&(name, kind)) else {
649 continue;
650 };
651 if source_style != *translation_style {
652 report.diagnostics.push(IcuDiagnostic::new(
653 IcuDiagnosticSeverity::Error,
654 diagnostic_codes::icu::FORMATTER_STYLE_CHANGED,
655 format!(
656 "Translation changes ICU formatter style for `{name}` from {source_style:?} to {translation_style:?}."
657 ),
658 Some(name.to_owned()),
659 ));
660 }
661 }
662}
663
664fn tag_counts(analysis: &IcuAnalysis) -> BTreeMap<&str, usize> {
665 let mut counts = BTreeMap::new();
666 for tag in &analysis.tags {
667 *counts.entry(tag.name.as_str()).or_insert(0) += 1;
668 }
669 counts
670}
671
672fn compare_tags(
673 source: &IcuAnalysis,
674 translation: &IcuAnalysis,
675 options: &IcuCompatibilityOptions,
676 report: &mut IcuCompatibilityReport,
677) {
678 let source_tags = tag_counts(source);
679 let translation_tags = tag_counts(translation);
680 for (name, source_count) in &source_tags {
681 let translation_count = translation_tags.get(name).copied().unwrap_or_default();
682 if translation_count < *source_count {
683 report.diagnostics.push(IcuDiagnostic::new(
684 IcuDiagnosticSeverity::Error,
685 diagnostic_codes::icu::MISSING_TAG,
686 format!("Translation is missing ICU tag `{name}`."),
687 Some((*name).to_owned()),
688 ));
689 }
690 }
691 if options.report_extra_tags {
692 for (name, translation_count) in &translation_tags {
693 let source_count = source_tags.get(name).copied().unwrap_or_default();
694 if *translation_count > source_count {
695 report.diagnostics.push(IcuDiagnostic::new(
696 IcuDiagnosticSeverity::Error,
697 diagnostic_codes::icu::EXTRA_TAG,
698 format!("Translation adds ICU tag `{name}` that is not present in source."),
699 Some((*name).to_owned()),
700 ));
701 }
702 }
703 }
704}
705
706fn select_map(analysis: &IcuAnalysis) -> BTreeMap<&str, BTreeSet<&str>> {
707 let mut out = BTreeMap::<&str, BTreeSet<&str>>::new();
708 for select in &analysis.selects {
709 out.entry(select.name.as_str())
710 .or_default()
711 .extend(selector_set(&select.selectors));
712 }
713 out
714}
715
716fn compare_selects(
717 source: &IcuAnalysis,
718 translation: &IcuAnalysis,
719 options: &IcuCompatibilityOptions,
720 report: &mut IcuCompatibilityReport,
721) {
722 let source_selects = select_map(source);
723 let translation_selects = select_map(translation);
724 for (name, source_selectors) in &source_selects {
725 let Some(translation_selectors) = translation_selects.get(name) else {
726 continue;
727 };
728 for &selector in source_selectors {
729 if !translation_selectors.contains(selector) {
730 report.diagnostics.push(IcuDiagnostic::new(
731 IcuDiagnosticSeverity::Error,
732 diagnostic_codes::icu::MISSING_SELECT_SELECTOR,
733 format!(
734 "Translation is missing ICU select selector `{selector}` for `{name}`."
735 ),
736 Some(format!("{name}:{selector}")),
737 ));
738 }
739 }
740 if options.report_extra_selectors {
741 for &selector in translation_selectors {
742 if !source_selectors.contains(selector) {
743 report.diagnostics.push(IcuDiagnostic::new(
744 IcuDiagnosticSeverity::Warning,
745 diagnostic_codes::icu::EXTRA_SELECT_SELECTOR,
746 format!("Translation adds ICU select selector `{selector}` for `{name}`."),
747 Some(format!("{name}:{selector}")),
748 ));
749 }
750 }
751 }
752 }
753}
754
755fn plural_key(plural: &IcuPluralSummary) -> (&str, IcuPluralKind) {
756 (plural.name.as_str(), plural.kind.clone())
757}
758
759fn plural_map(analysis: &IcuAnalysis) -> BTreeMap<(&str, IcuPluralKind), &IcuPluralSummary> {
760 analysis
761 .plurals
762 .iter()
763 .map(|plural| (plural_key(plural), plural))
764 .collect()
765}
766
767fn compare_plurals(
768 source: &IcuAnalysis,
769 translation: &IcuAnalysis,
770 report: &mut IcuCompatibilityReport,
771) {
772 let source_plurals = plural_map(source);
773 let translation_plurals = plural_map(translation);
774
775 for (key, source_plural) in source_plurals {
776 let Some(translation_plural) = translation_plurals.get(&key) else {
777 continue;
778 };
779 if source_plural.offset != translation_plural.offset {
780 report.diagnostics.push(IcuDiagnostic::new(
781 IcuDiagnosticSeverity::Error,
782 diagnostic_codes::icu::PLURAL_OFFSET_CHANGED,
783 format!(
784 "Translation changes ICU plural offset for `{}` from {} to {}.",
785 source_plural.name, source_plural.offset, translation_plural.offset
786 ),
787 Some(source_plural.name.clone()),
788 ));
789 }
790
791 let translation_selectors = selector_set(&translation_plural.selectors);
792 for selector in &source_plural.selectors {
793 let selector = selector.as_str();
794 if !translation_selectors.contains(selector) {
795 report.diagnostics.push(IcuDiagnostic::new(
796 IcuDiagnosticSeverity::Error,
797 diagnostic_codes::icu::MISSING_PLURAL_SELECTOR,
798 format!(
799 "Translation is missing ICU plural selector `{selector}` for `{}`.",
800 source_plural.name
801 ),
802 Some(format!("{}:{selector}", source_plural.name)),
803 ));
804 }
805 }
806 }
807}
808
809fn report_pattern_styles(analysis: &IcuAnalysis, label: &str, report: &mut IcuCompatibilityReport) {
810 for formatter in &analysis.formatters {
811 if !matches!(
812 formatter.kind,
813 IcuArgumentKind::Number | IcuArgumentKind::Date | IcuArgumentKind::Time
814 ) || formatter.style_kind != IcuStyleKind::Pattern
815 {
816 continue;
817 }
818 report.diagnostics.push(IcuDiagnostic::new(
819 IcuDiagnosticSeverity::Warning,
820 diagnostic_codes::icu::PATTERN_STYLE_DISCOURAGED,
821 format!(
822 "{label} ICU formatter `{}` uses an opaque pattern style; prefer a predefined style or `::` skeleton.",
823 formatter.name
824 ),
825 Some(formatter.name.clone()),
826 ));
827 }
828}
829
830#[cfg(test)]
831mod tests {
832 use crate::{
833 IcuArgumentKind, IcuCompatibilityOptions, IcuDiagnosticSeverity, IcuFormatterSupport,
834 IcuStyleKind, analyze_icu, compare_icu_messages, extract_argument_names, extract_tag_names,
835 parse_icu, validate_icu_formatter_support, validate_icu_formatter_support_from_analysis,
836 };
837
838 #[test]
839 fn analysis_separates_arguments_formatters_plurals_selects_and_tags() {
840 let message = parse_icu(
841 "<link>{gender, select, other {{count, plural, one {{when, date, short}} other {{count, number, ::compact-short}}}}}</link>",
842 )
843 .expect("parse");
844 let analysis = analyze_icu(&message);
845
846 assert_eq!(
847 extract_argument_names(&message),
848 vec!["gender", "count", "when"]
849 );
850 assert_eq!(extract_tag_names(&message), vec!["link"]);
851 assert_eq!(analysis.tags.len(), 1);
852 assert_eq!(analysis.selects.len(), 1);
853 assert_eq!(analysis.plurals.len(), 1);
854 assert_eq!(analysis.formatters.len(), 2);
855 assert!(
856 analysis
857 .arguments
858 .iter()
859 .any(|argument| argument.kind == IcuArgumentKind::Select)
860 );
861 }
862
863 #[test]
864 fn analysis_surfaces_self_closing_tag_names() {
865 let message = parse_icu("line one<0/>line two").expect("parse");
866 let analysis = analyze_icu(&message);
867
868 assert_eq!(extract_tag_names(&message), vec!["0"]);
869 assert_eq!(analysis.tags.len(), 1);
870 }
871
872 #[test]
873 fn analysis_classifies_formatter_styles() {
874 let message = parse_icu(
875 "{n, number, integer} {total, number, ::currency/USD} {created, date, yyyy-MM-dd} {items, list, disjunction}",
876 )
877 .expect("parse");
878 let analysis = analyze_icu(&message);
879 let kinds = analysis
880 .formatters
881 .iter()
882 .map(|formatter| formatter.style_kind)
883 .collect::<Vec<_>>();
884
885 assert_eq!(
886 kinds,
887 vec![
888 IcuStyleKind::Predefined,
889 IcuStyleKind::Skeleton,
890 IcuStyleKind::Pattern,
891 IcuStyleKind::Predefined
892 ]
893 );
894 }
895
896 #[test]
897 fn compatibility_reports_argument_formatter_tag_and_selector_mismatches() {
898 let source = parse_icu(
899 "<link>{gender, select, male {{count, number, integer} for {user}} female {{count, number, integer}} other {{count, number, integer}}}</link>",
900 )
901 .expect("parse source");
902 let translation = parse_icu(
903 "<b>{gender, select, male {{count}} other {{total, number, integer}} extra {{count}}}</b>",
904 )
905 .expect("parse translation");
906
907 let report =
908 compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
909 let codes = report
910 .diagnostics
911 .iter()
912 .map(|diagnostic| diagnostic.code.as_str())
913 .collect::<Vec<_>>();
914
915 assert!(report.has_errors());
916 assert!(codes.contains(&"icu.missing_argument"));
917 assert!(codes.contains(&"icu.extra_argument"));
918 assert!(codes.contains(&"icu.argument_kind_changed"));
919 assert!(codes.contains(&"icu.missing_tag"));
920 assert!(codes.contains(&"icu.extra_tag"));
921 assert!(codes.contains(&"icu.missing_select_selector"));
922 assert!(codes.contains(&"icu.extra_select_selector"));
923 }
924
925 #[test]
926 fn compatibility_allows_extra_plural_categories_but_reports_offset_changes() {
927 let source =
928 parse_icu("{count, plural, offset:1 one {# file} other {# files}}").expect("source");
929 let translation =
930 parse_icu("{count, plural, offset:2 one {# Datei} few {# Dateien} other {# Dateien}}")
931 .expect("translation");
932
933 let report =
934 compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
935
936 assert!(
937 report
938 .diagnostics
939 .iter()
940 .any(|diagnostic| diagnostic.code == "icu.plural_offset_changed")
941 );
942 assert!(
943 !report
944 .diagnostics
945 .iter()
946 .any(|diagnostic| diagnostic.code == "icu.extra_plural_selector")
947 );
948 }
949
950 #[test]
951 fn compatibility_reports_discouraged_pattern_styles_as_warnings() {
952 let source = parse_icu("{created, date, yyyy-MM-dd}").expect("source");
953 let translation = parse_icu("{created, date, yyyy-MM-dd}").expect("translation");
954
955 let report =
956 compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
957
958 assert!(report.diagnostics.iter().any(|diagnostic| {
959 diagnostic.code == "icu.pattern_style_discouraged"
960 && diagnostic.severity == IcuDiagnosticSeverity::Warning
961 }));
962 }
963
964 #[test]
965 fn formatter_support_validation_allows_supported_formatters() {
966 let message = parse_icu("{total, number, percent} {created, date, short}").expect("parse");
967
968 let report = validate_icu_formatter_support(&message, |_| IcuFormatterSupport::Supported);
969
970 assert!(report.diagnostics.is_empty());
971 }
972
973 #[test]
974 fn formatter_support_validation_reports_unsupported_kinds() {
975 let message = parse_icu("{items, list, conjunction}").expect("parse");
976
977 let report = validate_icu_formatter_support(&message, |formatter| {
978 if formatter.kind == IcuArgumentKind::List {
979 IcuFormatterSupport::UnsupportedKind {
980 severity: IcuDiagnosticSeverity::Error,
981 }
982 } else {
983 IcuFormatterSupport::Supported
984 }
985 });
986
987 assert_eq!(report.diagnostics.len(), 1);
988 assert_eq!(report.diagnostics[0].code, "icu.unsupported_formatter_kind");
989 assert_eq!(
990 report.diagnostics[0].message,
991 "ICU formatter `items` uses unsupported formatter kind `list`."
992 );
993 assert_eq!(report.diagnostics[0].severity, IcuDiagnosticSeverity::Error);
994 assert_eq!(report.diagnostics[0].name.as_deref(), Some("items"));
995 }
996
997 #[test]
998 fn formatter_support_validation_reports_unsupported_styles() {
999 let message = parse_icu("{total, number, ::compact-short}").expect("parse");
1000
1001 let report = validate_icu_formatter_support(&message, |formatter| {
1002 if formatter.style.as_deref() == Some("::compact-short") {
1003 IcuFormatterSupport::UnsupportedStyle {
1004 severity: IcuDiagnosticSeverity::Warning,
1005 }
1006 } else {
1007 IcuFormatterSupport::Supported
1008 }
1009 });
1010
1011 assert_eq!(report.diagnostics.len(), 1);
1012 assert_eq!(
1013 report.diagnostics[0].code,
1014 "icu.unsupported_formatter_style"
1015 );
1016 assert_eq!(
1017 report.diagnostics[0].message,
1018 "ICU formatter `total` uses unsupported `number` formatter style `::compact-short`."
1019 );
1020 assert_eq!(
1021 report.diagnostics[0].severity,
1022 IcuDiagnosticSeverity::Warning
1023 );
1024 assert_eq!(report.diagnostics[0].name.as_deref(), Some("total"));
1025 }
1026
1027 #[test]
1028 fn icu_argument_kind_display_uses_icu_formatter_names() {
1029 let labels = [
1030 IcuArgumentKind::Argument,
1031 IcuArgumentKind::Number,
1032 IcuArgumentKind::Date,
1033 IcuArgumentKind::Time,
1034 IcuArgumentKind::List,
1035 IcuArgumentKind::Duration,
1036 IcuArgumentKind::Ago,
1037 IcuArgumentKind::Name,
1038 IcuArgumentKind::Select,
1039 IcuArgumentKind::Plural,
1040 IcuArgumentKind::SelectOrdinal,
1041 ]
1042 .into_iter()
1043 .map(|kind| kind.to_string())
1044 .collect::<Vec<_>>();
1045
1046 assert_eq!(
1047 labels,
1048 vec![
1049 "argument",
1050 "number",
1051 "date",
1052 "time",
1053 "list",
1054 "duration",
1055 "ago",
1056 "name",
1057 "select",
1058 "plural",
1059 "selectordinal",
1060 ]
1061 );
1062 }
1063
1064 #[test]
1065 fn formatter_support_validation_reports_mixed_kind_and_style_decisions() {
1066 let message = parse_icu(
1067 "{total, number, ::compact-short} {created, date} {started, time, short} {items, list, conjunction}",
1068 )
1069 .expect("parse");
1070
1071 let report = validate_icu_formatter_support(&message, |formatter| match formatter.kind {
1072 IcuArgumentKind::Number if formatter.style_kind == IcuStyleKind::Skeleton => {
1073 IcuFormatterSupport::UnsupportedStyle {
1074 severity: IcuDiagnosticSeverity::Warning,
1075 }
1076 }
1077 IcuArgumentKind::Date if formatter.style.is_none() => {
1078 IcuFormatterSupport::UnsupportedStyle {
1079 severity: IcuDiagnosticSeverity::Error,
1080 }
1081 }
1082 IcuArgumentKind::List => IcuFormatterSupport::UnsupportedKind {
1083 severity: IcuDiagnosticSeverity::Error,
1084 },
1085 _ => IcuFormatterSupport::Supported,
1086 });
1087
1088 assert_eq!(
1089 report
1090 .diagnostics
1091 .iter()
1092 .map(|diagnostic| {
1093 (
1094 diagnostic.code.as_str(),
1095 diagnostic.severity,
1096 diagnostic.name.as_deref(),
1097 )
1098 })
1099 .collect::<Vec<_>>(),
1100 vec![
1101 (
1102 "icu.unsupported_formatter_style",
1103 IcuDiagnosticSeverity::Warning,
1104 Some("total")
1105 ),
1106 (
1107 "icu.unsupported_formatter_style",
1108 IcuDiagnosticSeverity::Error,
1109 Some("created")
1110 ),
1111 (
1112 "icu.unsupported_formatter_kind",
1113 IcuDiagnosticSeverity::Error,
1114 Some("items")
1115 ),
1116 ]
1117 );
1118 }
1119
1120 #[test]
1121 fn formatter_support_validation_accepts_precomputed_analysis() {
1122 let message = parse_icu("{total, number, integer} {created, date, short}").expect("parse");
1123 let analysis = analyze_icu(&message);
1124
1125 let report = validate_icu_formatter_support_from_analysis(&analysis, |_| {
1126 IcuFormatterSupport::Supported
1127 });
1128
1129 assert!(report.diagnostics.is_empty());
1130 }
1131
1132 #[test]
1133 fn formatter_support_validation_does_not_visit_plural_or_select_arguments() {
1134 let message = parse_icu(
1135 "{gender, select, other {{count, plural, one {# file} other {# files}}}} {created, date, short}",
1136 )
1137 .expect("parse");
1138 let analysis = analyze_icu(&message);
1139 let mut visited = Vec::new();
1140
1141 let report = validate_icu_formatter_support_from_analysis(&analysis, |formatter| {
1142 visited.push((formatter.name.clone(), formatter.kind));
1143 IcuFormatterSupport::Supported
1144 });
1145
1146 assert!(report.diagnostics.is_empty());
1147 assert_eq!(visited, vec![("created".to_owned(), IcuArgumentKind::Date)]);
1148 }
1149
1150 #[test]
1151 fn compatibility_option_builders_set_fields() {
1152 let options = IcuCompatibilityOptions::default()
1153 .with_report_extra_arguments(false)
1154 .with_report_extra_tags(false)
1155 .with_report_extra_selectors(false)
1156 .with_report_pattern_styles(false);
1157
1158 assert!(!options.report_extra_arguments);
1159 assert!(!options.report_extra_tags);
1160 assert!(!options.report_extra_selectors);
1161 assert!(!options.report_pattern_styles);
1162 }
1163}