1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::ast::{IcuMessage, IcuNode, IcuOption, IcuPluralKind};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum IcuDiagnosticSeverity {
8 Info,
10 Warning,
12 Error,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum IcuArgumentKind {
19 Argument,
21 Number,
23 Date,
25 Time,
27 List,
29 Duration,
31 Ago,
33 Name,
35 Select,
37 Plural,
39 SelectOrdinal,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum IcuStyleKind {
46 None,
48 Predefined,
50 Skeleton,
52 Pattern,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct IcuArgument {
59 pub name: String,
61 pub kind: IcuArgumentKind,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct IcuFormatter {
68 pub name: String,
70 pub kind: IcuArgumentKind,
72 pub style: Option<String>,
74 pub style_kind: IcuStyleKind,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct IcuSelectSummary {
81 pub name: String,
83 pub selectors: Vec<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct IcuPluralSummary {
90 pub name: String,
92 pub kind: IcuPluralKind,
94 pub offset: u32,
96 pub selectors: Vec<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct IcuTagSummary {
103 pub name: String,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub struct IcuAnalysis {
110 pub arguments: Vec<IcuArgument>,
112 pub formatters: Vec<IcuFormatter>,
114 pub plurals: Vec<IcuPluralSummary>,
116 pub selects: Vec<IcuSelectSummary>,
118 pub tags: Vec<IcuTagSummary>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct IcuCompatibilityOptions {
125 pub report_extra_arguments: bool,
127 pub report_extra_tags: bool,
129 pub report_extra_selectors: bool,
131 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#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct IcuDiagnostic {
149 pub severity: IcuDiagnosticSeverity,
151 pub code: String,
153 pub message: String,
155 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
177pub struct IcuCompatibilityReport {
178 pub diagnostics: Vec<IcuDiagnostic>,
180}
181
182impl IcuCompatibilityReport {
183 #[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#[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#[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#[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#[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}