Skip to main content

ferrocat_icu/
analysis.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt,
4};
5
6use crate::ast::{IcuMessage, IcuNode, IcuOption, IcuPluralKind};
7use crate::diagnostic_codes;
8
9/// Severity level attached to ICU authoring diagnostics.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum IcuDiagnosticSeverity {
12    /// Informational message that does not indicate a problem.
13    Info,
14    /// Non-fatal condition that may require author or translator attention.
15    Warning,
16    /// Structural incompatibility that can break runtime formatting.
17    Error,
18}
19
20/// Broad role of an ICU argument in a parsed message.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum IcuArgumentKind {
23    /// Simple substitution such as `{name}`.
24    Argument,
25    /// Number formatter such as `{count, number}`.
26    Number,
27    /// Date formatter such as `{created, date}`.
28    Date,
29    /// Time formatter such as `{created, time}`.
30    Time,
31    /// List formatter such as `{items, list}`.
32    List,
33    /// Duration formatter such as `{elapsed, duration}`.
34    Duration,
35    /// Relative-time "ago" formatter.
36    Ago,
37    /// Name formatter.
38    Name,
39    /// Select expression.
40    Select,
41    /// Cardinal plural expression.
42    Plural,
43    /// Ordinal plural expression.
44    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/// Classification of an optional formatter style segment.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum IcuStyleKind {
68    /// Formatter has no style segment.
69    None,
70    /// Formatter uses a known named style.
71    Predefined,
72    /// Formatter uses an ICU skeleton, identified by the `::` prefix.
73    Skeleton,
74    /// Formatter uses an opaque pattern-style segment.
75    Pattern,
76}
77
78/// One data argument reference discovered in an ICU message.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct IcuArgument {
81    /// Argument name referenced by the message.
82    pub name: String,
83    /// Structural role of the argument.
84    pub kind: IcuArgumentKind,
85}
86
87/// One formatter reference discovered in an ICU message.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct IcuFormatter {
90    /// Argument name passed to the formatter.
91    pub name: String,
92    /// Formatter kind.
93    pub kind: IcuArgumentKind,
94    /// Raw optional style segment.
95    pub style: Option<String>,
96    /// Classification of the raw style segment.
97    pub style_kind: IcuStyleKind,
98}
99
100/// Consumer-defined support decision for an ICU formatter.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum IcuFormatterSupport {
103    /// The formatter kind and style are supported by the consumer.
104    Supported,
105    /// The formatter kind is not supported by the consumer.
106    ///
107    /// Use this when a runtime cannot handle the formatter category at all,
108    /// such as rejecting every `list` formatter.
109    UnsupportedKind {
110        /// Severity to attach to the emitted diagnostic.
111        severity: IcuDiagnosticSeverity,
112    },
113    /// The formatter style is not supported by the consumer.
114    ///
115    /// Use this when a runtime accepts the formatter kind but rejects the
116    /// specific style. A missing style is treated as the formatter's default
117    /// style; reject it with this variant when the default style is unsupported.
118    UnsupportedStyle {
119        /// Severity to attach to the emitted diagnostic.
120        severity: IcuDiagnosticSeverity,
121    },
122}
123
124/// One select expression discovered in an ICU message.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct IcuSelectSummary {
127    /// Argument name used for selection.
128    pub name: String,
129    /// Selector labels in source order.
130    pub selectors: Vec<String>,
131}
132
133/// One cardinal or ordinal plural expression discovered in an ICU message.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct IcuPluralSummary {
136    /// Argument name used for plural selection.
137    pub name: String,
138    /// Whether the plural expression is cardinal or ordinal.
139    pub kind: IcuPluralKind,
140    /// Parsed plural offset.
141    pub offset: u32,
142    /// Selector labels in source order.
143    pub selectors: Vec<String>,
144}
145
146/// One rich-text tag discovered in an ICU message.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct IcuTagSummary {
149    /// Tag name without angle brackets.
150    pub name: String,
151}
152
153/// Structural summary of a parsed ICU message.
154#[derive(Debug, Clone, PartialEq, Eq, Default)]
155pub struct IcuAnalysis {
156    /// Data arguments referenced by the message, excluding rich-text tags.
157    pub arguments: Vec<IcuArgument>,
158    /// Formatter arguments referenced by the message.
159    pub formatters: Vec<IcuFormatter>,
160    /// Cardinal and ordinal plural expressions.
161    pub plurals: Vec<IcuPluralSummary>,
162    /// Select expressions.
163    pub selects: Vec<IcuSelectSummary>,
164    /// Rich-text tags.
165    pub tags: Vec<IcuTagSummary>,
166}
167
168/// Options controlling ICU source/translation compatibility checks.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct IcuCompatibilityOptions {
171    /// Whether translation-only data arguments should be reported.
172    pub report_extra_arguments: bool,
173    /// Whether translation-only rich-text tags should be reported.
174    pub report_extra_tags: bool,
175    /// Whether translation-only select selectors should be reported.
176    pub report_extra_selectors: bool,
177    /// Whether opaque number/date/time pattern styles should be reported.
178    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/// One diagnostic emitted by an ICU compatibility check.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct IcuDiagnostic {
195    /// Severity for the diagnostic.
196    pub severity: IcuDiagnosticSeverity,
197    /// Stable machine-readable diagnostic code.
198    pub code: String,
199    /// Human-readable explanation of the condition.
200    pub message: String,
201    /// Argument, selector, or tag name associated with the diagnostic.
202    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/// Report returned by [`compare_icu_messages`].
222#[derive(Debug, Clone, PartialEq, Eq, Default)]
223pub struct IcuCompatibilityReport {
224    /// Diagnostics found while comparing source and translation.
225    pub diagnostics: Vec<IcuDiagnostic>,
226}
227
228impl IcuCompatibilityReport {
229    /// Returns `true` when the report contains at least one error diagnostic.
230    #[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/// Produces a structural summary of a parsed ICU message.
239#[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/// Extracts data argument names in first-seen order, excluding rich-text tags.
247#[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/// Extracts rich-text tag names in first-seen order.
253#[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/// Validates ICU formatter usage against a consumer-provided support policy.
259///
260/// The support callback receives each formatter discovered by [`analyze_icu`]
261/// and decides whether the formatter is supported, has an unsupported kind, or
262/// has an unsupported style. This keeps runtime-specific support policy outside
263/// of `ferrocat-icu` while standardizing emitted diagnostics.
264#[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/// Validates analyzed ICU formatter usage against a consumer support policy.
274///
275/// Use this when the caller already has an [`IcuAnalysis`] from [`analyze_icu`]
276/// and wants to avoid walking the parsed message a second time. The callback is
277/// invoked only for formatter arguments such as `number`, `date`, `time`, or
278/// `list`; plural and select selector arguments are not formatter callbacks.
279#[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/// Compares source and translation ICU messages for authoring compatibility.
320#[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}