Skip to main content

ferrocat_icu/
analysis.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::ast::{IcuMessage, IcuNode, IcuOption, IcuPluralKind};
4
5/// Severity level attached to ICU authoring diagnostics.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum IcuDiagnosticSeverity {
8    /// Informational message that does not indicate a problem.
9    Info,
10    /// Non-fatal condition that may require author or translator attention.
11    Warning,
12    /// Structural incompatibility that can break runtime formatting.
13    Error,
14}
15
16/// Broad role of an ICU argument in a parsed message.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum IcuArgumentKind {
19    /// Simple substitution such as `{name}`.
20    Argument,
21    /// Number formatter such as `{count, number}`.
22    Number,
23    /// Date formatter such as `{created, date}`.
24    Date,
25    /// Time formatter such as `{created, time}`.
26    Time,
27    /// List formatter such as `{items, list}`.
28    List,
29    /// Duration formatter such as `{elapsed, duration}`.
30    Duration,
31    /// Relative-time "ago" formatter.
32    Ago,
33    /// Name formatter.
34    Name,
35    /// Select expression.
36    Select,
37    /// Cardinal plural expression.
38    Plural,
39    /// Ordinal plural expression.
40    SelectOrdinal,
41}
42
43/// Classification of an optional formatter style segment.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum IcuStyleKind {
46    /// Formatter has no style segment.
47    None,
48    /// Formatter uses a known named style.
49    Predefined,
50    /// Formatter uses an ICU skeleton, identified by the `::` prefix.
51    Skeleton,
52    /// Formatter uses an opaque pattern-style segment.
53    Pattern,
54}
55
56/// One data argument reference discovered in an ICU message.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct IcuArgument {
59    /// Argument name referenced by the message.
60    pub name: String,
61    /// Structural role of the argument.
62    pub kind: IcuArgumentKind,
63}
64
65/// One formatter reference discovered in an ICU message.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct IcuFormatter {
68    /// Argument name passed to the formatter.
69    pub name: String,
70    /// Formatter kind.
71    pub kind: IcuArgumentKind,
72    /// Raw optional style segment.
73    pub style: Option<String>,
74    /// Classification of the raw style segment.
75    pub style_kind: IcuStyleKind,
76}
77
78/// One select expression discovered in an ICU message.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct IcuSelectSummary {
81    /// Argument name used for selection.
82    pub name: String,
83    /// Selector labels in source order.
84    pub selectors: Vec<String>,
85}
86
87/// One cardinal or ordinal plural expression discovered in an ICU message.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct IcuPluralSummary {
90    /// Argument name used for plural selection.
91    pub name: String,
92    /// Whether the plural expression is cardinal or ordinal.
93    pub kind: IcuPluralKind,
94    /// Parsed plural offset.
95    pub offset: u32,
96    /// Selector labels in source order.
97    pub selectors: Vec<String>,
98}
99
100/// One rich-text tag discovered in an ICU message.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct IcuTagSummary {
103    /// Tag name without angle brackets.
104    pub name: String,
105}
106
107/// Structural summary of a parsed ICU message.
108#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub struct IcuAnalysis {
110    /// Data arguments referenced by the message, excluding rich-text tags.
111    pub arguments: Vec<IcuArgument>,
112    /// Formatter arguments referenced by the message.
113    pub formatters: Vec<IcuFormatter>,
114    /// Cardinal and ordinal plural expressions.
115    pub plurals: Vec<IcuPluralSummary>,
116    /// Select expressions.
117    pub selects: Vec<IcuSelectSummary>,
118    /// Rich-text tags.
119    pub tags: Vec<IcuTagSummary>,
120}
121
122/// Options controlling ICU source/translation compatibility checks.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct IcuCompatibilityOptions {
125    /// Whether translation-only data arguments should be reported.
126    pub report_extra_arguments: bool,
127    /// Whether translation-only rich-text tags should be reported.
128    pub report_extra_tags: bool,
129    /// Whether translation-only select selectors should be reported.
130    pub report_extra_selectors: bool,
131    /// Whether opaque number/date/time pattern styles should be reported.
132    pub report_pattern_styles: bool,
133}
134
135impl Default for IcuCompatibilityOptions {
136    fn default() -> Self {
137        Self {
138            report_extra_arguments: true,
139            report_extra_tags: true,
140            report_extra_selectors: true,
141            report_pattern_styles: true,
142        }
143    }
144}
145
146/// One diagnostic emitted by an ICU compatibility check.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct IcuDiagnostic {
149    /// Severity for the diagnostic.
150    pub severity: IcuDiagnosticSeverity,
151    /// Stable machine-readable diagnostic code.
152    pub code: String,
153    /// Human-readable explanation of the condition.
154    pub message: String,
155    /// Argument, selector, or tag name associated with the diagnostic.
156    pub name: Option<String>,
157}
158
159impl IcuDiagnostic {
160    fn new(
161        severity: IcuDiagnosticSeverity,
162        code: &'static str,
163        message: impl Into<String>,
164        name: impl Into<Option<String>>,
165    ) -> Self {
166        Self {
167            severity,
168            code: code.to_owned(),
169            message: message.into(),
170            name: name.into(),
171        }
172    }
173}
174
175/// Report returned by [`compare_icu_messages`].
176#[derive(Debug, Clone, PartialEq, Eq, Default)]
177pub struct IcuCompatibilityReport {
178    /// Diagnostics found while comparing source and translation.
179    pub diagnostics: Vec<IcuDiagnostic>,
180}
181
182impl IcuCompatibilityReport {
183    /// Returns `true` when the report contains at least one error diagnostic.
184    #[must_use]
185    pub fn has_errors(&self) -> bool {
186        self.diagnostics
187            .iter()
188            .any(|diagnostic| diagnostic.severity == IcuDiagnosticSeverity::Error)
189    }
190}
191
192/// Produces a structural summary of a parsed ICU message.
193#[must_use]
194pub fn analyze_icu(message: &IcuMessage) -> IcuAnalysis {
195    let mut analysis = IcuAnalysis::default();
196    visit_nodes(&message.nodes, &mut analysis);
197    analysis
198}
199
200/// Extracts data argument names in first-seen order, excluding rich-text tags.
201#[must_use]
202pub fn extract_argument_names(message: &IcuMessage) -> Vec<String> {
203    unique_names(analyze_icu(message).arguments.iter().map(|arg| &arg.name))
204}
205
206/// Extracts rich-text tag names in first-seen order.
207#[must_use]
208pub fn extract_tag_names(message: &IcuMessage) -> Vec<String> {
209    unique_names(analyze_icu(message).tags.iter().map(|tag| &tag.name))
210}
211
212/// Compares source and translation ICU messages for authoring compatibility.
213#[must_use]
214pub fn compare_icu_messages(
215    source: &IcuMessage,
216    translation: &IcuMessage,
217    options: &IcuCompatibilityOptions,
218) -> IcuCompatibilityReport {
219    let source = analyze_icu(source);
220    let translation = analyze_icu(translation);
221    let mut report = IcuCompatibilityReport::default();
222
223    compare_arguments(&source, &translation, options, &mut report);
224    compare_formatter_styles(&source, &translation, &mut report);
225    compare_tags(&source, &translation, options, &mut report);
226    compare_selects(&source, &translation, options, &mut report);
227    compare_plurals(&source, &translation, &mut report);
228    if options.report_pattern_styles {
229        report_pattern_styles(&source, "source", &mut report);
230        report_pattern_styles(&translation, "translation", &mut report);
231    }
232
233    report
234}
235
236fn visit_nodes(nodes: &[IcuNode], analysis: &mut IcuAnalysis) {
237    for node in nodes {
238        match node {
239            IcuNode::Literal(_) | IcuNode::Pound => {}
240            IcuNode::Argument { name } => {
241                push_argument(analysis, name, IcuArgumentKind::Argument);
242            }
243            IcuNode::Number { name, style } => {
244                push_formatter(analysis, name, IcuArgumentKind::Number, style);
245            }
246            IcuNode::Date { name, style } => {
247                push_formatter(analysis, name, IcuArgumentKind::Date, style);
248            }
249            IcuNode::Time { name, style } => {
250                push_formatter(analysis, name, IcuArgumentKind::Time, style);
251            }
252            IcuNode::List { name, style } => {
253                push_formatter(analysis, name, IcuArgumentKind::List, style);
254            }
255            IcuNode::Duration { name, style } => {
256                push_formatter(analysis, name, IcuArgumentKind::Duration, style);
257            }
258            IcuNode::Ago { name, style } => {
259                push_formatter(analysis, name, IcuArgumentKind::Ago, style);
260            }
261            IcuNode::Name { name, style } => {
262                push_formatter(analysis, name, IcuArgumentKind::Name, style);
263            }
264            IcuNode::Select { name, options } => {
265                push_argument(analysis, name, IcuArgumentKind::Select);
266                analysis.selects.push(IcuSelectSummary {
267                    name: name.clone(),
268                    selectors: selectors(options),
269                });
270                visit_options(options, analysis);
271            }
272            IcuNode::Plural {
273                name,
274                kind,
275                offset,
276                options,
277            } => {
278                let argument_kind = match kind {
279                    IcuPluralKind::Cardinal => IcuArgumentKind::Plural,
280                    IcuPluralKind::Ordinal => IcuArgumentKind::SelectOrdinal,
281                };
282                push_argument(analysis, name, argument_kind);
283                analysis.plurals.push(IcuPluralSummary {
284                    name: name.clone(),
285                    kind: kind.clone(),
286                    offset: *offset,
287                    selectors: selectors(options),
288                });
289                visit_options(options, analysis);
290            }
291            IcuNode::Tag { name, children } => {
292                analysis.tags.push(IcuTagSummary { name: name.clone() });
293                visit_nodes(children, analysis);
294            }
295        }
296    }
297}
298
299fn visit_options(options: &[IcuOption], analysis: &mut IcuAnalysis) {
300    for option in options {
301        visit_nodes(&option.value, analysis);
302    }
303}
304
305fn push_argument(analysis: &mut IcuAnalysis, name: &str, kind: IcuArgumentKind) {
306    analysis.arguments.push(IcuArgument {
307        name: name.to_owned(),
308        kind,
309    });
310}
311
312fn push_formatter(
313    analysis: &mut IcuAnalysis,
314    name: &str,
315    kind: IcuArgumentKind,
316    style: &Option<String>,
317) {
318    push_argument(analysis, name, kind);
319    analysis.formatters.push(IcuFormatter {
320        name: name.to_owned(),
321        kind,
322        style: style.clone(),
323        style_kind: classify_style(kind, style.as_deref()),
324    });
325}
326
327fn selectors(options: &[IcuOption]) -> Vec<String> {
328    options
329        .iter()
330        .map(|option| option.selector.clone())
331        .collect()
332}
333
334fn classify_style(kind: IcuArgumentKind, style: Option<&str>) -> IcuStyleKind {
335    let Some(style) = style.map(str::trim).filter(|style| !style.is_empty()) else {
336        return IcuStyleKind::None;
337    };
338    if style.starts_with("::") {
339        return IcuStyleKind::Skeleton;
340    }
341    if is_predefined_style(kind, style) {
342        return IcuStyleKind::Predefined;
343    }
344    IcuStyleKind::Pattern
345}
346
347fn is_predefined_style(kind: IcuArgumentKind, style: &str) -> bool {
348    match kind {
349        IcuArgumentKind::Number => matches!(style, "integer" | "currency" | "percent"),
350        IcuArgumentKind::Date | IcuArgumentKind::Time => {
351            matches!(style, "short" | "medium" | "long" | "full")
352        }
353        IcuArgumentKind::List => matches!(style, "conjunction" | "disjunction" | "unit"),
354        _ => false,
355    }
356}
357
358fn unique_names<'a>(names: impl IntoIterator<Item = &'a String>) -> Vec<String> {
359    let mut seen = BTreeSet::new();
360    let mut out = Vec::new();
361    for name in names {
362        if seen.insert(name.clone()) {
363            out.push(name.clone());
364        }
365    }
366    out
367}
368
369fn argument_map(analysis: &IcuAnalysis) -> BTreeMap<String, IcuArgumentKind> {
370    analysis
371        .arguments
372        .iter()
373        .map(|argument| (argument.name.clone(), argument.kind))
374        .collect()
375}
376
377fn formatter_map(analysis: &IcuAnalysis) -> BTreeMap<(String, IcuArgumentKind), Option<String>> {
378    analysis
379        .formatters
380        .iter()
381        .map(|formatter| {
382            (
383                (formatter.name.clone(), formatter.kind),
384                formatter.style.clone().map(|style| style.trim().to_owned()),
385            )
386        })
387        .collect()
388}
389
390fn selector_set(selectors: &[String]) -> BTreeSet<String> {
391    selectors.iter().cloned().collect()
392}
393
394fn compare_arguments(
395    source: &IcuAnalysis,
396    translation: &IcuAnalysis,
397    options: &IcuCompatibilityOptions,
398    report: &mut IcuCompatibilityReport,
399) {
400    let source_arguments = argument_map(source);
401    let translation_arguments = argument_map(translation);
402
403    for (name, source_kind) in &source_arguments {
404        let Some(translation_kind) = translation_arguments.get(name) else {
405            report.diagnostics.push(IcuDiagnostic::new(
406                IcuDiagnosticSeverity::Error,
407                "icu.missing_argument",
408                format!("Translation is missing ICU argument `{name}`."),
409                Some(name.clone()),
410            ));
411            continue;
412        };
413        if source_kind != translation_kind {
414            report.diagnostics.push(IcuDiagnostic::new(
415                IcuDiagnosticSeverity::Error,
416                "icu.argument_kind_changed",
417                format!(
418                    "Translation changes ICU argument `{name}` from {source_kind:?} to {translation_kind:?}."
419                ),
420                Some(name.clone()),
421            ));
422        }
423    }
424
425    if options.report_extra_arguments {
426        for name in translation_arguments.keys() {
427            if !source_arguments.contains_key(name) {
428                report.diagnostics.push(IcuDiagnostic::new(
429                    IcuDiagnosticSeverity::Error,
430                    "icu.extra_argument",
431                    format!(
432                        "Translation adds ICU argument `{name}` that is not present in source."
433                    ),
434                    Some(name.clone()),
435                ));
436            }
437        }
438    }
439}
440
441fn compare_formatter_styles(
442    source: &IcuAnalysis,
443    translation: &IcuAnalysis,
444    report: &mut IcuCompatibilityReport,
445) {
446    let source_formatters = formatter_map(source);
447    let translation_formatters = formatter_map(translation);
448
449    for ((name, kind), source_style) in source_formatters {
450        let Some(translation_style) = translation_formatters.get(&(name.clone(), kind)) else {
451            continue;
452        };
453        if source_style != *translation_style {
454            report.diagnostics.push(IcuDiagnostic::new(
455                IcuDiagnosticSeverity::Error,
456                "icu.formatter_style_changed",
457                format!(
458                    "Translation changes ICU formatter style for `{name}` from {source_style:?} to {translation_style:?}."
459                ),
460                Some(name),
461            ));
462        }
463    }
464}
465
466fn tag_counts(analysis: &IcuAnalysis) -> BTreeMap<String, usize> {
467    let mut counts = BTreeMap::new();
468    for tag in &analysis.tags {
469        *counts.entry(tag.name.clone()).or_insert(0) += 1;
470    }
471    counts
472}
473
474fn compare_tags(
475    source: &IcuAnalysis,
476    translation: &IcuAnalysis,
477    options: &IcuCompatibilityOptions,
478    report: &mut IcuCompatibilityReport,
479) {
480    let source_tags = tag_counts(source);
481    let translation_tags = tag_counts(translation);
482    for (name, source_count) in &source_tags {
483        let translation_count = translation_tags.get(name).copied().unwrap_or_default();
484        if translation_count < *source_count {
485            report.diagnostics.push(IcuDiagnostic::new(
486                IcuDiagnosticSeverity::Error,
487                "icu.missing_tag",
488                format!("Translation is missing ICU tag `{name}`."),
489                Some(name.clone()),
490            ));
491        }
492    }
493    if options.report_extra_tags {
494        for (name, translation_count) in &translation_tags {
495            let source_count = source_tags.get(name).copied().unwrap_or_default();
496            if *translation_count > source_count {
497                report.diagnostics.push(IcuDiagnostic::new(
498                    IcuDiagnosticSeverity::Error,
499                    "icu.extra_tag",
500                    format!("Translation adds ICU tag `{name}` that is not present in source."),
501                    Some(name.clone()),
502                ));
503            }
504        }
505    }
506}
507
508fn select_map(analysis: &IcuAnalysis) -> BTreeMap<String, BTreeSet<String>> {
509    let mut out = BTreeMap::<String, BTreeSet<String>>::new();
510    for select in &analysis.selects {
511        out.entry(select.name.clone())
512            .or_default()
513            .extend(selector_set(&select.selectors));
514    }
515    out
516}
517
518fn compare_selects(
519    source: &IcuAnalysis,
520    translation: &IcuAnalysis,
521    options: &IcuCompatibilityOptions,
522    report: &mut IcuCompatibilityReport,
523) {
524    let source_selects = select_map(source);
525    let translation_selects = select_map(translation);
526    for (name, source_selectors) in &source_selects {
527        let Some(translation_selectors) = translation_selects.get(name) else {
528            continue;
529        };
530        for selector in source_selectors {
531            if !translation_selectors.contains(selector) {
532                report.diagnostics.push(IcuDiagnostic::new(
533                    IcuDiagnosticSeverity::Error,
534                    "icu.missing_select_selector",
535                    format!(
536                        "Translation is missing ICU select selector `{selector}` for `{name}`."
537                    ),
538                    Some(format!("{name}:{selector}")),
539                ));
540            }
541        }
542        if options.report_extra_selectors {
543            for selector in translation_selectors {
544                if !source_selectors.contains(selector) {
545                    report.diagnostics.push(IcuDiagnostic::new(
546                        IcuDiagnosticSeverity::Warning,
547                        "icu.extra_select_selector",
548                        format!("Translation adds ICU select selector `{selector}` for `{name}`."),
549                        Some(format!("{name}:{selector}")),
550                    ));
551                }
552            }
553        }
554    }
555}
556
557fn plural_key(plural: &IcuPluralSummary) -> (String, IcuPluralKind) {
558    (plural.name.clone(), plural.kind.clone())
559}
560
561fn plural_map(analysis: &IcuAnalysis) -> BTreeMap<(String, IcuPluralKind), IcuPluralSummary> {
562    analysis
563        .plurals
564        .iter()
565        .map(|plural| (plural_key(plural), plural.clone()))
566        .collect()
567}
568
569fn compare_plurals(
570    source: &IcuAnalysis,
571    translation: &IcuAnalysis,
572    report: &mut IcuCompatibilityReport,
573) {
574    let source_plurals = plural_map(source);
575    let translation_plurals = plural_map(translation);
576
577    for (key, source_plural) in source_plurals {
578        let Some(translation_plural) = translation_plurals.get(&key) else {
579            continue;
580        };
581        if source_plural.offset != translation_plural.offset {
582            report.diagnostics.push(IcuDiagnostic::new(
583                IcuDiagnosticSeverity::Error,
584                "icu.plural_offset_changed",
585                format!(
586                    "Translation changes ICU plural offset for `{}` from {} to {}.",
587                    source_plural.name, source_plural.offset, translation_plural.offset
588                ),
589                Some(source_plural.name.clone()),
590            ));
591        }
592
593        let translation_selectors = selector_set(&translation_plural.selectors);
594        for selector in &source_plural.selectors {
595            if !translation_selectors.contains(selector) {
596                report.diagnostics.push(IcuDiagnostic::new(
597                    IcuDiagnosticSeverity::Error,
598                    "icu.missing_plural_selector",
599                    format!(
600                        "Translation is missing ICU plural selector `{selector}` for `{}`.",
601                        source_plural.name
602                    ),
603                    Some(format!("{}:{selector}", source_plural.name)),
604                ));
605            }
606        }
607    }
608}
609
610fn report_pattern_styles(analysis: &IcuAnalysis, label: &str, report: &mut IcuCompatibilityReport) {
611    for formatter in &analysis.formatters {
612        if !matches!(
613            formatter.kind,
614            IcuArgumentKind::Number | IcuArgumentKind::Date | IcuArgumentKind::Time
615        ) || formatter.style_kind != IcuStyleKind::Pattern
616        {
617            continue;
618        }
619        report.diagnostics.push(IcuDiagnostic::new(
620            IcuDiagnosticSeverity::Warning,
621            "icu.pattern_style_discouraged",
622            format!(
623                "{label} ICU formatter `{}` uses an opaque pattern style; prefer a predefined style or `::` skeleton.",
624                formatter.name
625            ),
626            Some(formatter.name.clone()),
627        ));
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use crate::{
634        IcuArgumentKind, IcuCompatibilityOptions, IcuDiagnosticSeverity, IcuStyleKind, analyze_icu,
635        compare_icu_messages, extract_argument_names, extract_tag_names, parse_icu,
636    };
637
638    #[test]
639    fn analysis_separates_arguments_formatters_plurals_selects_and_tags() {
640        let message = parse_icu(
641            "<link>{gender, select, other {{count, plural, one {{when, date, short}} other {{count, number, ::compact-short}}}}}</link>",
642        )
643        .expect("parse");
644        let analysis = analyze_icu(&message);
645
646        assert_eq!(
647            extract_argument_names(&message),
648            vec!["gender", "count", "when"]
649        );
650        assert_eq!(extract_tag_names(&message), vec!["link"]);
651        assert_eq!(analysis.tags.len(), 1);
652        assert_eq!(analysis.selects.len(), 1);
653        assert_eq!(analysis.plurals.len(), 1);
654        assert_eq!(analysis.formatters.len(), 2);
655        assert!(
656            analysis
657                .arguments
658                .iter()
659                .any(|argument| argument.kind == IcuArgumentKind::Select)
660        );
661    }
662
663    #[test]
664    fn analysis_classifies_formatter_styles() {
665        let message = parse_icu(
666            "{n, number, integer} {total, number, ::currency/USD} {created, date, yyyy-MM-dd} {items, list, disjunction}",
667        )
668        .expect("parse");
669        let analysis = analyze_icu(&message);
670        let kinds = analysis
671            .formatters
672            .iter()
673            .map(|formatter| formatter.style_kind)
674            .collect::<Vec<_>>();
675
676        assert_eq!(
677            kinds,
678            vec![
679                IcuStyleKind::Predefined,
680                IcuStyleKind::Skeleton,
681                IcuStyleKind::Pattern,
682                IcuStyleKind::Predefined
683            ]
684        );
685    }
686
687    #[test]
688    fn compatibility_reports_argument_formatter_tag_and_selector_mismatches() {
689        let source = parse_icu(
690            "<link>{gender, select, male {{count, number, integer} for {user}} female {{count, number, integer}} other {{count, number, integer}}}</link>",
691        )
692        .expect("parse source");
693        let translation = parse_icu(
694            "<b>{gender, select, male {{count}} other {{total, number, integer}} extra {{count}}}</b>",
695        )
696        .expect("parse translation");
697
698        let report =
699            compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
700        let codes = report
701            .diagnostics
702            .iter()
703            .map(|diagnostic| diagnostic.code.as_str())
704            .collect::<Vec<_>>();
705
706        assert!(report.has_errors());
707        assert!(codes.contains(&"icu.missing_argument"));
708        assert!(codes.contains(&"icu.extra_argument"));
709        assert!(codes.contains(&"icu.argument_kind_changed"));
710        assert!(codes.contains(&"icu.missing_tag"));
711        assert!(codes.contains(&"icu.extra_tag"));
712        assert!(codes.contains(&"icu.missing_select_selector"));
713        assert!(codes.contains(&"icu.extra_select_selector"));
714    }
715
716    #[test]
717    fn compatibility_allows_extra_plural_categories_but_reports_offset_changes() {
718        let source =
719            parse_icu("{count, plural, offset:1 one {# file} other {# files}}").expect("source");
720        let translation =
721            parse_icu("{count, plural, offset:2 one {# Datei} few {# Dateien} other {# Dateien}}")
722                .expect("translation");
723
724        let report =
725            compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
726
727        assert!(
728            report
729                .diagnostics
730                .iter()
731                .any(|diagnostic| diagnostic.code == "icu.plural_offset_changed")
732        );
733        assert!(
734            !report
735                .diagnostics
736                .iter()
737                .any(|diagnostic| diagnostic.code == "icu.extra_plural_selector")
738        );
739    }
740
741    #[test]
742    fn compatibility_reports_discouraged_pattern_styles_as_warnings() {
743        let source = parse_icu("{created, date, yyyy-MM-dd}").expect("source");
744        let translation = parse_icu("{created, date, yyyy-MM-dd}").expect("translation");
745
746        let report =
747            compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
748
749        assert!(report.diagnostics.iter().any(|diagnostic| {
750            diagnostic.code == "icu.pattern_style_discouraged"
751                && diagnostic.severity == IcuDiagnosticSeverity::Warning
752        }));
753    }
754}