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