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