1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use crate::{Header, MsgStr, ParseError, PoFile, PoItem, SerializeOptions, parse_po, stringify_po};
8use ferrocat_icu::{IcuMessage, IcuNode, IcuPluralKind, parse_icu};
9use icu_locale::Locale;
10use icu_plurals::{PluralCategory, PluralRules};
11
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct CatalogOrigin {
14 pub file: String,
15 pub line: Option<u32>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct ExtractedSingularMessage {
20 pub msgid: String,
21 pub msgctxt: Option<String>,
22 pub comments: Vec<String>,
23 pub origin: Vec<CatalogOrigin>,
24 pub placeholders: BTreeMap<String, Vec<String>>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct PluralSource {
29 pub one: Option<String>,
30 pub other: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub struct ExtractedPluralMessage {
35 pub msgid: String,
36 pub msgctxt: Option<String>,
37 pub source: PluralSource,
38 pub comments: Vec<String>,
39 pub origin: Vec<CatalogOrigin>,
40 pub placeholders: BTreeMap<String, Vec<String>>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ExtractedMessage {
45 Singular(ExtractedSingularMessage),
46 Plural(ExtractedPluralMessage),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum TranslationShape {
51 Singular {
52 value: String,
53 },
54 Plural {
55 source: PluralSource,
56 translation: BTreeMap<String, String>,
57 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub struct CatalogMessageExtra {
62 pub translator_comments: Vec<String>,
63 pub flags: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct CatalogMessage {
68 pub msgid: String,
69 pub msgctxt: Option<String>,
70 pub translation: TranslationShape,
71 pub comments: Vec<String>,
72 pub origin: Vec<CatalogOrigin>,
73 pub obsolete: bool,
74 pub extra: Option<CatalogMessageExtra>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum DiagnosticSeverity {
79 Info,
80 Warning,
81 Error,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Diagnostic {
86 pub severity: DiagnosticSeverity,
87 pub code: String,
88 pub message: String,
89 pub msgid: Option<String>,
90 pub msgctxt: Option<String>,
91}
92
93impl Diagnostic {
94 fn new(
95 severity: DiagnosticSeverity,
96 code: impl Into<String>,
97 message: impl Into<String>,
98 ) -> Self {
99 Self {
100 severity,
101 code: code.into(),
102 message: message.into(),
103 msgid: None,
104 msgctxt: None,
105 }
106 }
107
108 fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
109 self.msgid = Some(msgid.to_owned());
110 self.msgctxt = msgctxt.map(str::to_owned);
111 self
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Default)]
116pub struct CatalogStats {
117 pub total: usize,
118 pub added: usize,
119 pub changed: usize,
120 pub unchanged: usize,
121 pub obsolete_marked: usize,
122 pub obsolete_removed: usize,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct CatalogUpdateResult {
127 pub content: String,
128 pub created: bool,
129 pub updated: bool,
130 pub stats: CatalogStats,
131 pub diagnostics: Vec<Diagnostic>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ParsedCatalog {
136 pub locale: Option<String>,
137 pub headers: BTreeMap<String, String>,
138 pub messages: Vec<CatalogMessage>,
139 pub diagnostics: Vec<Diagnostic>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum PluralEncoding {
144 #[default]
145 Icu,
146 Gettext,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ObsoleteStrategy {
151 #[default]
152 Mark,
153 Delete,
154 Keep,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
158pub enum OrderBy {
159 #[default]
160 Msgid,
161 Origin,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum PlaceholderCommentMode {
166 Disabled,
167 Enabled { limit: usize },
168}
169
170impl Default for PlaceholderCommentMode {
171 fn default() -> Self {
172 Self::Enabled { limit: 3 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct UpdateCatalogOptions {
178 pub locale: Option<String>,
179 pub source_locale: String,
180 pub extracted: Vec<ExtractedMessage>,
181 pub existing: Option<String>,
182 pub plural_encoding: PluralEncoding,
183 pub obsolete_strategy: ObsoleteStrategy,
184 pub overwrite_source_translations: bool,
185 pub order_by: OrderBy,
186 pub include_origins: bool,
187 pub include_line_numbers: bool,
188 pub print_placeholders_in_comments: PlaceholderCommentMode,
189 pub custom_header_attributes: BTreeMap<String, String>,
190}
191
192impl Default for UpdateCatalogOptions {
193 fn default() -> Self {
194 Self {
195 locale: None,
196 source_locale: String::new(),
197 extracted: Vec::new(),
198 existing: None,
199 plural_encoding: PluralEncoding::Icu,
200 obsolete_strategy: ObsoleteStrategy::Mark,
201 overwrite_source_translations: false,
202 order_by: OrderBy::Msgid,
203 include_origins: true,
204 include_line_numbers: true,
205 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
206 custom_header_attributes: BTreeMap::new(),
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct UpdateCatalogFileOptions {
213 pub target_path: PathBuf,
214 pub locale: Option<String>,
215 pub source_locale: String,
216 pub extracted: Vec<ExtractedMessage>,
217 pub plural_encoding: PluralEncoding,
218 pub obsolete_strategy: ObsoleteStrategy,
219 pub overwrite_source_translations: bool,
220 pub order_by: OrderBy,
221 pub include_origins: bool,
222 pub include_line_numbers: bool,
223 pub print_placeholders_in_comments: PlaceholderCommentMode,
224 pub custom_header_attributes: BTreeMap<String, String>,
225}
226
227impl Default for UpdateCatalogFileOptions {
228 fn default() -> Self {
229 Self {
230 target_path: PathBuf::new(),
231 locale: None,
232 source_locale: String::new(),
233 extracted: Vec::new(),
234 plural_encoding: PluralEncoding::Icu,
235 obsolete_strategy: ObsoleteStrategy::Mark,
236 overwrite_source_translations: false,
237 order_by: OrderBy::Msgid,
238 include_origins: true,
239 include_line_numbers: true,
240 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
241 custom_header_attributes: BTreeMap::new(),
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct ParseCatalogOptions {
248 pub content: String,
249 pub locale: Option<String>,
250 pub source_locale: String,
251 pub plural_encoding: PluralEncoding,
252 pub strict: bool,
253}
254
255impl Default for ParseCatalogOptions {
256 fn default() -> Self {
257 Self {
258 content: String::new(),
259 locale: None,
260 source_locale: String::new(),
261 plural_encoding: PluralEncoding::Icu,
262 strict: false,
263 }
264 }
265}
266
267#[derive(Debug)]
268pub enum ApiError {
269 Parse(ParseError),
270 Io(std::io::Error),
271 InvalidArguments(String),
272 Conflict(String),
273 Unsupported(String),
274}
275
276impl fmt::Display for ApiError {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 match self {
279 Self::Parse(error) => error.fmt(f),
280 Self::Io(error) => error.fmt(f),
281 Self::InvalidArguments(message)
282 | Self::Conflict(message)
283 | Self::Unsupported(message) => f.write_str(message),
284 }
285 }
286}
287
288impl std::error::Error for ApiError {}
289
290impl From<ParseError> for ApiError {
291 fn from(value: ParseError) -> Self {
292 Self::Parse(value)
293 }
294}
295
296impl From<std::io::Error> for ApiError {
297 fn from(value: std::io::Error) -> Self {
298 Self::Io(value)
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Default)]
303struct Catalog {
304 locale: Option<String>,
305 headers: BTreeMap<String, String>,
306 file_comments: Vec<String>,
307 file_extracted_comments: Vec<String>,
308 messages: Vec<CanonicalMessage>,
309 diagnostics: Vec<Diagnostic>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313struct CanonicalMessage {
314 msgid: String,
315 msgctxt: Option<String>,
316 translation: CanonicalTranslation,
317 comments: Vec<String>,
318 origins: Vec<CatalogOrigin>,
319 placeholders: BTreeMap<String, Vec<String>>,
320 obsolete: bool,
321 translator_comments: Vec<String>,
322 flags: Vec<String>,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326enum CanonicalTranslation {
327 Singular {
328 value: String,
329 },
330 Plural {
331 source: PluralSource,
332 translation_by_category: BTreeMap<String, String>,
333 variable: String,
334 },
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
338struct NormalizedMessage {
339 msgid: String,
340 msgctxt: Option<String>,
341 kind: NormalizedKind,
342 comments: Vec<String>,
343 origins: Vec<CatalogOrigin>,
344 placeholders: BTreeMap<String, Vec<String>>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
348enum NormalizedKind {
349 Singular,
350 Plural(PluralSource),
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
354struct ParsedIcuPlural {
355 variable: String,
356 branches: BTreeMap<String, String>,
357}
358
359enum IcuPluralProjection {
360 NotPlural,
361 Projected(ParsedIcuPlural),
362 Unsupported(&'static str),
363 Malformed,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Default)]
367struct ParsedPluralFormsHeader {
368 raw: Option<String>,
369 nplurals: Option<usize>,
370 plural: Option<String>,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
374struct PluralProfile {
375 categories: Vec<String>,
376}
377
378impl PluralProfile {
379 fn new(locale: Option<&str>, nplurals: Option<usize>) -> Self {
380 let categories = if let Some(locale_categories) = locale.and_then(icu_plural_categories_for)
381 {
382 if nplurals.is_none() || nplurals == Some(locale_categories.len()) {
383 locale_categories
384 } else {
385 fallback_plural_categories(nplurals)
386 }
387 } else {
388 fallback_plural_categories(nplurals)
389 };
390
391 Self { categories }
392 }
393
394 fn for_locale(locale: Option<&str>) -> Self {
395 Self::new(locale, None)
396 }
397
398 fn for_gettext_slots(locale: Option<&str>, nplurals: Option<usize>) -> Self {
399 Self::new(locale, nplurals)
400 }
401
402 fn for_translation(
403 locale: Option<&str>,
404 translation_by_category: &BTreeMap<String, String>,
405 ) -> Self {
406 Self::new(locale, Some(translation_by_category.len()))
407 }
408
409 fn categories(&self) -> &[String] {
410 &self.categories
411 }
412
413 fn nplurals(&self) -> usize {
414 self.categories.len().max(1)
415 }
416
417 fn materialize_translation(
418 &self,
419 translation: &BTreeMap<String, String>,
420 ) -> BTreeMap<String, String> {
421 self.categories
422 .iter()
423 .map(|category| {
424 (
425 category.clone(),
426 translation.get(category).cloned().unwrap_or_default(),
427 )
428 })
429 .collect()
430 }
431
432 fn source_locale_translation(&self, source: &PluralSource) -> BTreeMap<String, String> {
433 let mut translation = BTreeMap::new();
434 for category in &self.categories {
435 let value = match category.as_str() {
436 "one" => source.one.clone().unwrap_or_else(|| source.other.clone()),
437 "other" => source.other.clone(),
438 _ => source.other.clone(),
439 };
440 translation.insert(category.clone(), value);
441 }
442 translation
443 }
444
445 fn empty_translation(&self) -> BTreeMap<String, String> {
446 self.categories
447 .iter()
448 .map(|category| (category.clone(), String::new()))
449 .collect()
450 }
451
452 fn gettext_values(&self, translation: &BTreeMap<String, String>) -> Vec<String> {
453 self.categories
454 .iter()
455 .map(|category| translation.get(category).cloned().unwrap_or_default())
456 .collect()
457 }
458
459 fn gettext_header(&self) -> Option<String> {
460 match self.nplurals() {
461 1 => Some("nplurals=1; plural=0;".to_owned()),
462 2 => Some("nplurals=2; plural=(n != 1);".to_owned()),
463 _ => None,
464 }
465 }
466}
467
468pub fn update_catalog(options: UpdateCatalogOptions) -> Result<CatalogUpdateResult, ApiError> {
469 validate_source_locale(&options.source_locale)?;
470
471 let created = options.existing.is_none();
472 let original = options.existing.as_deref().unwrap_or("");
473 let existing = match options.existing.as_deref() {
474 Some(content) if !content.is_empty() => parse_catalog_to_internal(
475 content,
476 options.locale.as_deref(),
477 options.plural_encoding,
478 false,
479 )?,
480 Some(_) | None => Catalog {
481 locale: options.locale.clone(),
482 headers: BTreeMap::new(),
483 file_comments: Vec::new(),
484 file_extracted_comments: Vec::new(),
485 messages: Vec::new(),
486 diagnostics: Vec::new(),
487 },
488 };
489
490 let locale = options
491 .locale
492 .clone()
493 .or_else(|| existing.locale.clone())
494 .or_else(|| existing.headers.get("Language").cloned());
495 let normalized = normalize_extracted(&options.extracted)?;
496 let mut diagnostics = existing.diagnostics.clone();
497 let (mut merged, stats) = merge_catalogs(
498 existing,
499 &normalized,
500 locale.as_deref(),
501 &options.source_locale,
502 options.overwrite_source_translations,
503 options.obsolete_strategy,
504 &mut diagnostics,
505 );
506 merged.locale = locale.clone();
507 apply_header_defaults(
508 &mut merged.headers,
509 locale.as_deref(),
510 options.plural_encoding,
511 &mut diagnostics,
512 &options.custom_header_attributes,
513 );
514 sort_messages(&mut merged.messages, options.order_by);
515 let file = export_catalog_to_po(&merged, &options, locale.as_deref(), &mut diagnostics)?;
516 let content = stringify_po(&file, &SerializeOptions::default());
517
518 Ok(CatalogUpdateResult {
519 updated: content != original,
520 content,
521 created,
522 stats,
523 diagnostics,
524 })
525}
526
527pub fn update_catalog_file(
528 options: UpdateCatalogFileOptions,
529) -> Result<CatalogUpdateResult, ApiError> {
530 validate_source_locale(&options.source_locale)?;
531 if options.target_path.as_os_str().is_empty() {
532 return Err(ApiError::InvalidArguments(
533 "target_path must not be empty".to_owned(),
534 ));
535 }
536
537 let existing = match fs::read_to_string(&options.target_path) {
538 Ok(content) => Some(content),
539 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
540 Err(error) => return Err(ApiError::Io(error)),
541 };
542
543 let result = update_catalog(UpdateCatalogOptions {
544 locale: options.locale,
545 source_locale: options.source_locale,
546 extracted: options.extracted,
547 existing,
548 plural_encoding: options.plural_encoding,
549 obsolete_strategy: options.obsolete_strategy,
550 overwrite_source_translations: options.overwrite_source_translations,
551 order_by: options.order_by,
552 include_origins: options.include_origins,
553 include_line_numbers: options.include_line_numbers,
554 print_placeholders_in_comments: options.print_placeholders_in_comments,
555 custom_header_attributes: options.custom_header_attributes,
556 })?;
557
558 if result.created || result.updated {
559 atomic_write(&options.target_path, &result.content)?;
560 }
561
562 Ok(result)
563}
564
565pub fn parse_catalog(options: ParseCatalogOptions) -> Result<ParsedCatalog, ApiError> {
566 validate_source_locale(&options.source_locale)?;
567 let catalog = parse_catalog_to_internal(
568 &options.content,
569 options.locale.as_deref(),
570 options.plural_encoding,
571 options.strict,
572 )?;
573 let messages = catalog
574 .messages
575 .into_iter()
576 .map(public_message_from_canonical)
577 .collect();
578
579 Ok(ParsedCatalog {
580 locale: catalog.locale,
581 headers: catalog.headers,
582 messages,
583 diagnostics: catalog.diagnostics,
584 })
585}
586
587fn validate_source_locale(source_locale: &str) -> Result<(), ApiError> {
588 if source_locale.trim().is_empty() {
589 return Err(ApiError::InvalidArguments(
590 "source_locale must not be empty".to_owned(),
591 ));
592 }
593 Ok(())
594}
595
596fn normalize_extracted(extracted: &[ExtractedMessage]) -> Result<Vec<NormalizedMessage>, ApiError> {
597 let mut index = BTreeMap::<(String, Option<String>), usize>::new();
598 let mut normalized = Vec::<NormalizedMessage>::new();
599
600 for message in extracted {
601 let (msgid, msgctxt, kind, comments, origins, placeholders) = match message {
602 ExtractedMessage::Singular(message) => (
603 message.msgid.clone(),
604 message.msgctxt.clone(),
605 NormalizedKind::Singular,
606 message.comments.clone(),
607 message.origin.clone(),
608 message.placeholders.clone(),
609 ),
610 ExtractedMessage::Plural(message) => (
611 message.msgid.clone(),
612 message.msgctxt.clone(),
613 NormalizedKind::Plural(message.source.clone()),
614 message.comments.clone(),
615 message.origin.clone(),
616 message.placeholders.clone(),
617 ),
618 };
619
620 if msgid.is_empty() {
621 return Err(ApiError::InvalidArguments(
622 "extracted msgid must not be empty".to_owned(),
623 ));
624 }
625
626 let key = (msgid.clone(), msgctxt.clone());
627 match index.get(&key).copied() {
628 Some(existing_index) => {
629 let existing = &mut normalized[existing_index];
630 if existing.kind != kind {
631 return Err(ApiError::Conflict(format!(
632 "conflicting duplicate extracted message for msgid {:?}",
633 msgid
634 )));
635 }
636 merge_unique_strings(&mut existing.comments, comments);
637 merge_unique_origins(&mut existing.origins, origins);
638 merge_placeholders(&mut existing.placeholders, placeholders);
639 }
640 None => {
641 index.insert(key, normalized.len());
642 normalized.push(NormalizedMessage {
643 msgid,
644 msgctxt,
645 kind,
646 comments: dedupe_strings(comments),
647 origins: dedupe_origins(origins),
648 placeholders: dedupe_placeholders(placeholders),
649 });
650 }
651 }
652 }
653
654 Ok(normalized)
655}
656
657fn merge_catalogs(
658 existing: Catalog,
659 normalized: &[NormalizedMessage],
660 locale: Option<&str>,
661 source_locale: &str,
662 overwrite_source_translations: bool,
663 obsolete_strategy: ObsoleteStrategy,
664 diagnostics: &mut Vec<Diagnostic>,
665) -> (Catalog, CatalogStats) {
666 let is_source_locale = locale.is_none_or(|value| value == source_locale);
667 let mut stats = CatalogStats::default();
668
669 let mut existing_index = BTreeMap::<(String, Option<String>), usize>::new();
670 for (index, message) in existing.messages.iter().enumerate() {
671 existing_index.insert((message.msgid.clone(), message.msgctxt.clone()), index);
672 }
673
674 let mut matched = vec![false; existing.messages.len()];
675 let mut messages = Vec::with_capacity(normalized.len() + existing.messages.len());
676
677 for next in normalized {
678 let key = (next.msgid.clone(), next.msgctxt.clone());
679 let previous = existing_index.get(&key).copied().map(|index| {
680 matched[index] = true;
681 existing.messages[index].clone()
682 });
683 let merged = merge_message(
684 previous.as_ref(),
685 next,
686 is_source_locale,
687 locale,
688 overwrite_source_translations,
689 diagnostics,
690 );
691 if previous.is_none() {
692 stats.added += 1;
693 } else if previous.as_ref() == Some(&merged) {
694 stats.unchanged += 1;
695 } else {
696 stats.changed += 1;
697 }
698 messages.push(merged);
699 }
700
701 for (index, message) in existing.messages.into_iter().enumerate() {
702 if matched[index] {
703 continue;
704 }
705 match obsolete_strategy {
706 ObsoleteStrategy::Delete => {
707 stats.obsolete_removed += 1;
708 }
709 ObsoleteStrategy::Mark => {
710 let mut message = message;
711 if !message.obsolete {
712 message.obsolete = true;
713 stats.obsolete_marked += 1;
714 }
715 messages.push(message);
716 }
717 ObsoleteStrategy::Keep => {
718 let mut message = message;
719 message.obsolete = false;
720 messages.push(message);
721 }
722 }
723 }
724
725 stats.total = messages.len();
726 (
727 Catalog {
728 locale: existing.locale,
729 headers: existing.headers,
730 file_comments: existing.file_comments,
731 file_extracted_comments: existing.file_extracted_comments,
732 messages,
733 diagnostics: existing.diagnostics,
734 },
735 stats,
736 )
737}
738
739fn merge_message(
740 previous: Option<&CanonicalMessage>,
741 next: &NormalizedMessage,
742 is_source_locale: bool,
743 locale: Option<&str>,
744 overwrite_source_translations: bool,
745 diagnostics: &mut Vec<Diagnostic>,
746) -> CanonicalMessage {
747 let plural_profile = match &next.kind {
748 NormalizedKind::Singular => None,
749 NormalizedKind::Plural(_) => Some(PluralProfile::for_locale(locale)),
750 };
751
752 let translation = match (&next.kind, previous) {
753 (NormalizedKind::Singular, Some(previous))
754 if matches!(previous.translation, CanonicalTranslation::Singular { .. })
755 && !(is_source_locale && overwrite_source_translations) =>
756 {
757 previous.translation.clone()
758 }
759 (NormalizedKind::Plural(source), Some(previous))
760 if matches!(previous.translation, CanonicalTranslation::Plural { .. })
761 && !(is_source_locale && overwrite_source_translations) =>
762 {
763 let previous_variable = match &previous.translation {
764 CanonicalTranslation::Plural { variable, .. } => variable.clone(),
765 _ => unreachable!(),
766 };
767 let previous_map = match &previous.translation {
768 CanonicalTranslation::Plural {
769 translation_by_category,
770 ..
771 } => translation_by_category.clone(),
772 _ => unreachable!(),
773 };
774 CanonicalTranslation::Plural {
775 source: source.clone(),
776 translation_by_category: plural_profile
777 .as_ref()
778 .expect("plural messages require plural profile")
779 .materialize_translation(&previous_map),
780 variable: previous_variable,
781 }
782 }
783 (NormalizedKind::Singular, _) => CanonicalTranslation::Singular {
784 value: if is_source_locale {
785 next.msgid.clone()
786 } else {
787 String::new()
788 },
789 },
790 (NormalizedKind::Plural(source), previous) => {
791 let variable = previous
792 .and_then(extract_plural_variable)
793 .or_else(|| derive_plural_variable(&next.placeholders))
794 .unwrap_or_else(|| {
795 diagnostics.push(
796 Diagnostic::new(
797 DiagnosticSeverity::Warning,
798 "plural.assumed_variable",
799 "Unable to determine plural placeholder name, assuming \"count\".",
800 )
801 .with_identity(&next.msgid, next.msgctxt.as_deref()),
802 );
803 "count".to_owned()
804 });
805
806 CanonicalTranslation::Plural {
807 source: source.clone(),
808 translation_by_category: if is_source_locale {
809 plural_profile
810 .as_ref()
811 .expect("plural messages require plural profile")
812 .source_locale_translation(source)
813 } else {
814 plural_profile
815 .as_ref()
816 .expect("plural messages require plural profile")
817 .empty_translation()
818 },
819 variable,
820 }
821 }
822 };
823
824 let (translator_comments, flags, obsolete) = previous
825 .map(|message| {
826 (
827 message.translator_comments.clone(),
828 message.flags.clone(),
829 false,
830 )
831 })
832 .unwrap_or_else(|| (Vec::new(), Vec::new(), false));
833
834 CanonicalMessage {
835 msgid: next.msgid.clone(),
836 msgctxt: next.msgctxt.clone(),
837 translation,
838 comments: next.comments.clone(),
839 origins: next.origins.clone(),
840 placeholders: next.placeholders.clone(),
841 obsolete,
842 translator_comments,
843 flags,
844 }
845}
846
847fn materialize_plural_categories(
848 categories: &[String],
849 translation: BTreeMap<String, String>,
850) -> BTreeMap<String, String> {
851 categories
852 .iter()
853 .map(|category| {
854 (
855 category.clone(),
856 translation.get(category).cloned().unwrap_or_default(),
857 )
858 })
859 .collect()
860}
861
862fn extract_plural_variable(message: &CanonicalMessage) -> Option<String> {
863 match &message.translation {
864 CanonicalTranslation::Plural { variable, .. } => Some(variable.clone()),
865 CanonicalTranslation::Singular { .. } => None,
866 }
867}
868
869fn apply_header_defaults(
870 headers: &mut BTreeMap<String, String>,
871 locale: Option<&str>,
872 plural_encoding: PluralEncoding,
873 diagnostics: &mut Vec<Diagnostic>,
874 custom: &BTreeMap<String, String>,
875) {
876 headers
877 .entry("MIME-Version".to_owned())
878 .or_insert_with(|| "1.0".to_owned());
879 headers
880 .entry("Content-Type".to_owned())
881 .or_insert_with(|| "text/plain; charset=utf-8".to_owned());
882 headers
883 .entry("Content-Transfer-Encoding".to_owned())
884 .or_insert_with(|| "8bit".to_owned());
885 headers
886 .entry("X-Generator".to_owned())
887 .or_insert_with(|| "ferrocat".to_owned());
888 if let Some(locale) = locale {
889 headers.insert("Language".to_owned(), locale.to_owned());
890 }
891 if plural_encoding == PluralEncoding::Gettext && !custom.contains_key("Plural-Forms") {
892 let profile = PluralProfile::for_locale(locale);
893 let parsed_header = parse_plural_forms_from_headers(headers);
894 match (parsed_header.raw.as_deref(), profile.gettext_header()) {
895 (None, Some(header)) => {
896 headers.insert("Plural-Forms".to_owned(), header);
897 }
898 (None, None) => diagnostics.push(Diagnostic::new(
899 DiagnosticSeverity::Info,
900 "plural.missing_plural_forms_header",
901 "No safe default Plural-Forms header is known for this locale; keeping the header unset.",
902 )),
903 (Some(_), Some(header))
904 if parsed_header.nplurals == Some(profile.nplurals())
905 && parsed_header.plural.is_none() =>
906 {
907 headers.insert("Plural-Forms".to_owned(), header);
908 diagnostics.push(Diagnostic::new(
909 DiagnosticSeverity::Info,
910 "plural.completed_plural_forms_header",
911 "Plural-Forms header was missing the plural expression and has been completed using a safe locale default.",
912 ));
913 }
914 _ => {}
915 }
916 }
917 for (key, value) in custom {
918 headers.insert(key.clone(), value.clone());
919 }
920}
921
922fn sort_messages(messages: &mut [CanonicalMessage], order_by: OrderBy) {
923 match order_by {
924 OrderBy::Msgid => messages.sort_by(|left, right| {
925 left.msgid
926 .cmp(&right.msgid)
927 .then_with(|| left.msgctxt.cmp(&right.msgctxt))
928 .then_with(|| left.obsolete.cmp(&right.obsolete))
929 }),
930 OrderBy::Origin => messages.sort_by(|left, right| {
931 first_origin_sort_key(&left.origins)
932 .cmp(&first_origin_sort_key(&right.origins))
933 .then_with(|| left.msgid.cmp(&right.msgid))
934 .then_with(|| left.msgctxt.cmp(&right.msgctxt))
935 }),
936 }
937}
938
939fn first_origin_sort_key(origins: &[CatalogOrigin]) -> (String, Option<u32>) {
940 origins
941 .first()
942 .map(|origin| (origin.file.clone(), origin.line))
943 .unwrap_or_else(|| (String::new(), None))
944}
945
946fn export_catalog_to_po(
947 catalog: &Catalog,
948 options: &UpdateCatalogOptions,
949 locale: Option<&str>,
950 diagnostics: &mut Vec<Diagnostic>,
951) -> Result<PoFile, ApiError> {
952 let mut file = PoFile {
953 comments: catalog.file_comments.clone(),
954 extracted_comments: catalog.file_extracted_comments.clone(),
955 headers: catalog
956 .headers
957 .iter()
958 .map(|(key, value)| Header {
959 key: key.clone(),
960 value: value.clone(),
961 })
962 .collect(),
963 items: Vec::with_capacity(catalog.messages.len()),
964 };
965
966 for message in &catalog.messages {
967 file.items
968 .push(export_message_to_po(message, options, locale, diagnostics)?);
969 }
970
971 Ok(file)
972}
973
974fn export_message_to_po(
975 message: &CanonicalMessage,
976 options: &UpdateCatalogOptions,
977 locale: Option<&str>,
978 diagnostics: &mut Vec<Diagnostic>,
979) -> Result<PoItem, ApiError> {
980 let plural_profile = match &message.translation {
981 CanonicalTranslation::Plural {
982 translation_by_category,
983 ..
984 } => Some(PluralProfile::for_translation(
985 locale,
986 translation_by_category,
987 )),
988 CanonicalTranslation::Singular { .. } => None,
989 };
990 let nplurals = match &message.translation {
991 CanonicalTranslation::Plural {
992 translation_by_category,
993 ..
994 } => plural_profile
995 .as_ref()
996 .expect("plural messages require plural profile")
997 .nplurals()
998 .max(translation_by_category.len().max(1)),
999 CanonicalTranslation::Singular { .. } => 1,
1000 };
1001 let mut item = PoItem::new(nplurals);
1002 item.msgctxt = message.msgctxt.clone();
1003 item.comments = message.translator_comments.clone();
1004 item.flags = message.flags.clone();
1005 item.obsolete = message.obsolete;
1006 item.extracted_comments = message.comments.clone();
1007 append_placeholder_comments(
1008 &mut item.extracted_comments,
1009 &message.placeholders,
1010 &options.print_placeholders_in_comments,
1011 );
1012 item.references = if options.include_origins {
1013 message
1014 .origins
1015 .iter()
1016 .map(|origin| {
1017 if options.include_line_numbers {
1018 match origin.line {
1019 Some(line) => format!("{}:{line}", origin.file),
1020 None => origin.file.clone(),
1021 }
1022 } else {
1023 origin.file.clone()
1024 }
1025 })
1026 .collect()
1027 } else {
1028 Vec::new()
1029 };
1030
1031 match (&message.translation, options.plural_encoding) {
1032 (CanonicalTranslation::Singular { value }, _) => {
1033 item.msgid = message.msgid.clone();
1034 item.msgstr = MsgStr::from(value.clone());
1035 }
1036 (
1037 CanonicalTranslation::Plural {
1038 source,
1039 translation_by_category,
1040 variable,
1041 },
1042 PluralEncoding::Icu,
1043 ) => {
1044 item.msgid = synthesize_icu_plural(variable, &plural_source_branches(source));
1045 item.msgstr = MsgStr::from(synthesize_icu_plural(variable, translation_by_category));
1046 }
1047 (
1048 CanonicalTranslation::Plural {
1049 source,
1050 translation_by_category,
1051 ..
1052 },
1053 PluralEncoding::Gettext,
1054 ) => {
1055 if !translation_by_category.contains_key("other") {
1056 diagnostics.push(
1057 Diagnostic::new(
1058 DiagnosticSeverity::Error,
1059 "plural.unsupported_gettext_export",
1060 "Plural translation is missing the required \"other\" category.",
1061 )
1062 .with_identity(&message.msgid, message.msgctxt.as_deref()),
1063 );
1064 return Err(ApiError::Unsupported(
1065 "plural translation is missing the required \"other\" category".to_owned(),
1066 ));
1067 }
1068 item.msgid = source.one.clone().unwrap_or_else(|| source.other.clone());
1069 item.msgid_plural = Some(source.other.clone());
1070 item.msgstr = MsgStr::from(
1071 plural_profile
1072 .as_ref()
1073 .expect("plural messages require plural profile")
1074 .gettext_values(translation_by_category),
1075 );
1076 item.nplurals = plural_profile
1077 .as_ref()
1078 .expect("plural messages require plural profile")
1079 .nplurals();
1080 }
1081 }
1082
1083 Ok(item)
1084}
1085
1086fn plural_source_branches(source: &PluralSource) -> BTreeMap<String, String> {
1087 let mut map = BTreeMap::new();
1088 if let Some(one) = &source.one {
1089 map.insert("one".to_owned(), one.clone());
1090 }
1091 map.insert("other".to_owned(), source.other.clone());
1092 map
1093}
1094
1095fn append_placeholder_comments(
1096 comments: &mut Vec<String>,
1097 placeholders: &BTreeMap<String, Vec<String>>,
1098 mode: &PlaceholderCommentMode,
1099) {
1100 let limit = match mode {
1101 PlaceholderCommentMode::Disabled => return,
1102 PlaceholderCommentMode::Enabled { limit } => *limit,
1103 };
1104
1105 let mut seen = comments.iter().cloned().collect::<BTreeSet<String>>();
1106
1107 for (name, values) in placeholders {
1108 if !name.chars().all(|ch| ch.is_ascii_digit()) {
1109 continue;
1110 }
1111 for value in values.iter().take(limit) {
1112 let comment = format!(
1113 "placeholder {{{name}}}: {}",
1114 normalize_placeholder_value(value)
1115 );
1116 if seen.insert(comment.clone()) {
1117 comments.push(comment);
1118 }
1119 }
1120 }
1121}
1122
1123fn normalize_placeholder_value(value: &str) -> String {
1124 value.replace('\n', " ")
1125}
1126
1127fn parse_catalog_to_internal(
1128 content: &str,
1129 locale_override: Option<&str>,
1130 plural_encoding: PluralEncoding,
1131 strict: bool,
1132) -> Result<Catalog, ApiError> {
1133 let file = parse_po(content)?;
1134 let headers = file
1135 .headers
1136 .iter()
1137 .map(|header| (header.key.clone(), header.value.clone()))
1138 .collect::<BTreeMap<_, _>>();
1139 let locale = locale_override
1140 .map(str::to_owned)
1141 .or_else(|| headers.get("Language").cloned());
1142 let plural_forms = parse_plural_forms_from_headers(&headers);
1143 let nplurals = plural_forms.nplurals;
1144 let mut diagnostics = Vec::new();
1145 validate_plural_forms_header(
1146 locale.as_deref(),
1147 &plural_forms,
1148 plural_encoding,
1149 &mut diagnostics,
1150 );
1151 let mut messages = Vec::with_capacity(file.items.len());
1152
1153 for item in file.items {
1154 let mut conversion_diagnostics = Vec::new();
1155 let message = import_message_from_po(
1156 item,
1157 locale.as_deref(),
1158 nplurals,
1159 plural_encoding,
1160 strict,
1161 &mut conversion_diagnostics,
1162 )?;
1163 diagnostics.extend(conversion_diagnostics);
1164 messages.push(message);
1165 }
1166
1167 Ok(Catalog {
1168 locale,
1169 headers,
1170 file_comments: file.comments,
1171 file_extracted_comments: file.extracted_comments,
1172 messages,
1173 diagnostics,
1174 })
1175}
1176
1177fn import_message_from_po(
1178 item: PoItem,
1179 locale: Option<&str>,
1180 nplurals: Option<usize>,
1181 plural_encoding: PluralEncoding,
1182 strict: bool,
1183 diagnostics: &mut Vec<Diagnostic>,
1184) -> Result<CanonicalMessage, ApiError> {
1185 let (comments, placeholders) = split_placeholder_comments(item.extracted_comments);
1186 let origins = item
1187 .references
1188 .iter()
1189 .map(|reference| parse_origin(reference))
1190 .collect();
1191
1192 let translation = if let Some(msgid_plural) = &item.msgid_plural {
1193 let plural_profile =
1194 PluralProfile::for_gettext_slots(locale, nplurals.or(Some(item.msgstr.len())));
1195 CanonicalTranslation::Plural {
1196 source: PluralSource {
1197 one: Some(item.msgid.clone()),
1198 other: msgid_plural.clone(),
1199 },
1200 translation_by_category: plural_profile
1201 .categories()
1202 .iter()
1203 .enumerate()
1204 .map(|(index, category)| {
1205 (
1206 category.clone(),
1207 item.msgstr.iter().nth(index).cloned().unwrap_or_default(),
1208 )
1209 })
1210 .collect(),
1211 variable: "count".to_owned(),
1212 }
1213 } else {
1214 let msgstr = item.msgstr.first_str().unwrap_or_default().to_owned();
1215 if plural_encoding == PluralEncoding::Icu {
1216 match project_icu_plural(&item.msgid) {
1217 IcuPluralProjection::Projected(source_plural) => {
1218 let translated_projection = project_icu_plural(&msgstr);
1219 match translated_projection {
1220 IcuPluralProjection::Projected(translated_plural)
1221 if translated_plural.variable == source_plural.variable =>
1222 {
1223 CanonicalTranslation::Plural {
1224 source: PluralSource {
1225 one: source_plural.branches.get("one").cloned(),
1226 other: source_plural
1227 .branches
1228 .get("other")
1229 .cloned()
1230 .unwrap_or_else(|| item.msgid.clone()),
1231 },
1232 translation_by_category: materialize_plural_categories(
1233 &sorted_plural_keys(&translated_plural.branches),
1234 translated_plural.branches,
1235 ),
1236 variable: source_plural.variable,
1237 }
1238 }
1239 IcuPluralProjection::Projected(_) => {
1240 if strict {
1241 return Err(ApiError::Unsupported(
1242 "ICU plural source and translation use different variables"
1243 .to_owned(),
1244 ));
1245 }
1246 diagnostics.push(
1247 Diagnostic::new(
1248 DiagnosticSeverity::Warning,
1249 "plural.partial_icu_parse",
1250 "Could not safely align ICU plural source and translation; keeping the message as singular.",
1251 )
1252 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1253 );
1254 CanonicalTranslation::Singular { value: msgstr }
1255 }
1256 IcuPluralProjection::Unsupported(_) | IcuPluralProjection::Malformed => {
1257 if strict
1258 && matches!(translated_projection, IcuPluralProjection::Malformed)
1259 {
1260 return Err(ApiError::Unsupported(
1261 "ICU plural message could not be parsed in strict mode"
1262 .to_owned(),
1263 ));
1264 }
1265 diagnostics.push(
1266 Diagnostic::new(
1267 DiagnosticSeverity::Warning,
1268 "plural.partial_icu_parse",
1269 "Could not fully parse ICU plural translation; keeping the message as singular.",
1270 )
1271 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1272 );
1273 CanonicalTranslation::Singular { value: msgstr }
1274 }
1275 IcuPluralProjection::NotPlural => {
1276 diagnostics.push(
1277 Diagnostic::new(
1278 DiagnosticSeverity::Warning,
1279 "plural.partial_icu_parse",
1280 "Could not fully parse ICU plural translation; keeping the message as singular.",
1281 )
1282 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1283 );
1284 CanonicalTranslation::Singular { value: msgstr }
1285 }
1286 }
1287 }
1288 IcuPluralProjection::NotPlural => CanonicalTranslation::Singular { value: msgstr },
1289 IcuPluralProjection::Malformed if strict => {
1290 return Err(ApiError::Unsupported(
1291 "ICU plural parsing failed in strict mode".to_owned(),
1292 ));
1293 }
1294 IcuPluralProjection::Unsupported(message) => {
1295 diagnostics.push(
1296 Diagnostic::new(
1297 DiagnosticSeverity::Warning,
1298 "plural.unsupported_icu_projection",
1299 message,
1300 )
1301 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1302 );
1303 CanonicalTranslation::Singular { value: msgstr }
1304 }
1305 IcuPluralProjection::Malformed => CanonicalTranslation::Singular { value: msgstr },
1306 }
1307 } else {
1308 CanonicalTranslation::Singular { value: msgstr }
1309 }
1310 };
1311
1312 Ok(CanonicalMessage {
1313 msgid: item.msgid,
1314 msgctxt: item.msgctxt,
1315 translation,
1316 comments,
1317 origins,
1318 placeholders,
1319 obsolete: item.obsolete,
1320 translator_comments: item.comments,
1321 flags: item.flags,
1322 })
1323}
1324
1325fn split_placeholder_comments(
1326 extracted_comments: Vec<String>,
1327) -> (Vec<String>, BTreeMap<String, Vec<String>>) {
1328 let mut comments = Vec::new();
1329 let mut placeholders = BTreeMap::<String, Vec<String>>::new();
1330
1331 for comment in extracted_comments {
1332 if let Some((name, value)) = parse_placeholder_comment(&comment) {
1333 placeholders.entry(name).or_default().push(value);
1334 } else {
1335 comments.push(comment);
1336 }
1337 }
1338
1339 (comments, dedupe_placeholders(placeholders))
1340}
1341
1342fn parse_placeholder_comment(comment: &str) -> Option<(String, String)> {
1343 let rest = comment.strip_prefix("placeholder {")?;
1344 let end = rest.find("}: ")?;
1345 Some((rest[..end].to_owned(), rest[end + 3..].to_owned()))
1346}
1347
1348fn parse_origin(reference: &str) -> CatalogOrigin {
1349 match reference.rsplit_once(':') {
1350 Some((file, line)) if line.chars().all(|ch| ch.is_ascii_digit()) => CatalogOrigin {
1351 file: file.to_owned(),
1352 line: line.parse::<u32>().ok(),
1353 },
1354 _ => CatalogOrigin {
1355 file: reference.to_owned(),
1356 line: None,
1357 },
1358 }
1359}
1360
1361fn parse_plural_forms_from_headers(headers: &BTreeMap<String, String>) -> ParsedPluralFormsHeader {
1362 let Some(plural_forms) = headers.get("Plural-Forms") else {
1363 return ParsedPluralFormsHeader::default();
1364 };
1365
1366 let mut parsed = ParsedPluralFormsHeader {
1367 raw: Some(plural_forms.clone()),
1368 ..ParsedPluralFormsHeader::default()
1369 };
1370 for part in plural_forms.split(';') {
1371 let trimmed = part.trim();
1372 if let Some(value) = trimmed.strip_prefix("nplurals=") {
1373 parsed.nplurals = value.trim().parse().ok();
1374 } else if let Some(value) = trimmed.strip_prefix("plural=") {
1375 let value = value.trim();
1376 if !value.is_empty() {
1377 parsed.plural = Some(value.to_owned());
1378 }
1379 }
1380 }
1381
1382 parsed
1383}
1384
1385fn validate_plural_forms_header(
1386 locale: Option<&str>,
1387 plural_forms: &ParsedPluralFormsHeader,
1388 plural_encoding: PluralEncoding,
1389 diagnostics: &mut Vec<Diagnostic>,
1390) {
1391 if plural_encoding != PluralEncoding::Gettext {
1392 return;
1393 }
1394
1395 if let Some(nplurals) = plural_forms.nplurals {
1396 let profile = PluralProfile::for_locale(locale);
1397 let expected = profile.nplurals();
1398 if locale.is_some() && nplurals != expected {
1399 diagnostics.push(Diagnostic::new(
1400 DiagnosticSeverity::Warning,
1401 "plural.nplurals_locale_mismatch",
1402 format!(
1403 "Plural-Forms declares nplurals={nplurals}, but locale-derived categories expect {expected}."
1404 ),
1405 ));
1406 }
1407 } else if plural_forms.plural.is_some() {
1408 diagnostics.push(Diagnostic::new(
1409 DiagnosticSeverity::Warning,
1410 "parse.invalid_plural_forms_header",
1411 "Plural-Forms header contains a plural expression but no parseable nplurals value.",
1412 ));
1413 }
1414
1415 if plural_forms.nplurals.is_some() && plural_forms.plural.is_none() {
1416 diagnostics.push(Diagnostic::new(
1417 DiagnosticSeverity::Info,
1418 "plural.missing_plural_expression",
1419 "Plural-Forms header declares nplurals but omits the plural expression.",
1420 ));
1421 }
1422}
1423
1424fn public_message_from_canonical(message: CanonicalMessage) -> CatalogMessage {
1425 let translation = match message.translation {
1426 CanonicalTranslation::Singular { value } => TranslationShape::Singular { value },
1427 CanonicalTranslation::Plural {
1428 source,
1429 translation_by_category,
1430 ..
1431 } => TranslationShape::Plural {
1432 source,
1433 translation: translation_by_category,
1434 },
1435 };
1436
1437 CatalogMessage {
1438 msgid: message.msgid,
1439 msgctxt: message.msgctxt,
1440 translation,
1441 comments: message.comments,
1442 origin: message.origins,
1443 obsolete: message.obsolete,
1444 extra: Some(CatalogMessageExtra {
1445 translator_comments: message.translator_comments,
1446 flags: message.flags,
1447 }),
1448 }
1449}
1450
1451fn icu_plural_categories_for(locale: &str) -> Option<Vec<String>> {
1452 static CACHE: OnceLock<Mutex<HashMap<String, Option<Vec<String>>>>> = OnceLock::new();
1453
1454 let normalized = normalize_plural_locale(locale);
1455 if normalized.is_empty() {
1456 return None;
1457 }
1458
1459 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1460 if let Some(cached) = cache
1461 .lock()
1462 .expect("plural category cache mutex poisoned")
1463 .get(&normalized)
1464 .cloned()
1465 {
1466 return cached;
1467 }
1468
1469 let resolved = normalized
1470 .parse::<Locale>()
1471 .ok()
1472 .and_then(|locale| PluralRules::try_new_cardinal(locale.into()).ok())
1473 .map(|rules| {
1474 rules
1475 .categories()
1476 .map(plural_category_name)
1477 .map(str::to_owned)
1478 .collect::<Vec<_>>()
1479 });
1480
1481 cache
1482 .lock()
1483 .expect("plural category cache mutex poisoned")
1484 .insert(normalized, resolved.clone());
1485
1486 resolved
1487}
1488
1489fn normalize_plural_locale(locale: &str) -> String {
1490 locale.trim().replace('_', "-")
1491}
1492
1493fn plural_category_name(category: PluralCategory) -> &'static str {
1494 match category {
1495 PluralCategory::Zero => "zero",
1496 PluralCategory::One => "one",
1497 PluralCategory::Two => "two",
1498 PluralCategory::Few => "few",
1499 PluralCategory::Many => "many",
1500 PluralCategory::Other => "other",
1501 }
1502}
1503
1504fn fallback_plural_categories(nplurals: Option<usize>) -> Vec<String> {
1505 let categories = match nplurals.unwrap_or(2) {
1506 0 | 1 => vec!["other"],
1507 2 => vec!["one", "other"],
1508 3 => vec!["one", "few", "other"],
1509 4 => vec!["one", "few", "many", "other"],
1510 5 => vec!["zero", "one", "few", "many", "other"],
1511 _ => vec!["zero", "one", "two", "few", "many", "other"],
1512 };
1513
1514 categories.into_iter().map(str::to_owned).collect()
1515}
1516
1517fn sorted_plural_keys(map: &BTreeMap<String, String>) -> Vec<String> {
1518 let mut keys: Vec<_> = map.keys().cloned().collect();
1519 keys.sort_by_key(|key| plural_key_rank(key));
1520 if !keys.iter().any(|key| key == "other") {
1521 keys.push("other".to_owned());
1522 }
1523 keys
1524}
1525
1526fn plural_key_rank(key: &str) -> usize {
1527 match key {
1528 "zero" => 0,
1529 "one" => 1,
1530 "two" => 2,
1531 "few" => 3,
1532 "many" => 4,
1533 "other" => 5,
1534 _ => 6,
1535 }
1536}
1537
1538fn dedupe_strings(values: Vec<String>) -> Vec<String> {
1539 let mut out = Vec::new();
1540 for value in values {
1541 if !push_unique_string(&out, &value) {
1542 out.push(value);
1543 }
1544 }
1545 out
1546}
1547
1548fn merge_unique_strings(target: &mut Vec<String>, incoming: Vec<String>) {
1549 if target.len() + incoming.len() < 8 {
1550 for value in incoming {
1551 if !push_unique_string(target, &value) {
1552 target.push(value);
1553 }
1554 }
1555 return;
1556 }
1557
1558 let mut seen = target.iter().cloned().collect::<BTreeSet<_>>();
1559 for value in incoming {
1560 if seen.insert(value.clone()) {
1561 target.push(value);
1562 }
1563 }
1564}
1565
1566fn push_unique_string(target: &[String], value: &str) -> bool {
1567 if target.len() < 8 {
1568 target.iter().any(|existing| existing == value)
1569 } else {
1570 target
1571 .iter()
1572 .cloned()
1573 .collect::<BTreeSet<_>>()
1574 .contains(value)
1575 }
1576}
1577
1578fn dedupe_origins(values: Vec<CatalogOrigin>) -> Vec<CatalogOrigin> {
1579 let mut out = Vec::new();
1580 for value in values {
1581 if !push_unique_origin(&out, &value) {
1582 out.push(value);
1583 }
1584 }
1585 out
1586}
1587
1588fn merge_unique_origins(target: &mut Vec<CatalogOrigin>, incoming: Vec<CatalogOrigin>) {
1589 if target.len() + incoming.len() < 8 {
1590 for value in incoming {
1591 if !push_unique_origin(target, &value) {
1592 target.push(value);
1593 }
1594 }
1595 return;
1596 }
1597
1598 let mut seen = target
1599 .iter()
1600 .map(|origin| (origin.file.clone(), origin.line))
1601 .collect::<BTreeSet<_>>();
1602 for value in incoming {
1603 if seen.insert((value.file.clone(), value.line)) {
1604 target.push(value);
1605 }
1606 }
1607}
1608
1609fn push_unique_origin(target: &[CatalogOrigin], value: &CatalogOrigin) -> bool {
1610 if target.len() < 8 {
1611 target
1612 .iter()
1613 .any(|origin| origin.file == value.file && origin.line == value.line)
1614 } else {
1615 target
1616 .iter()
1617 .map(|origin| (origin.file.clone(), origin.line))
1618 .collect::<BTreeSet<_>>()
1619 .contains(&(value.file.clone(), value.line))
1620 }
1621}
1622
1623fn dedupe_placeholders(
1624 placeholders: BTreeMap<String, Vec<String>>,
1625) -> BTreeMap<String, Vec<String>> {
1626 placeholders
1627 .into_iter()
1628 .map(|(key, values)| (key, dedupe_strings(values)))
1629 .collect()
1630}
1631
1632fn merge_placeholders(
1633 target: &mut BTreeMap<String, Vec<String>>,
1634 incoming: BTreeMap<String, Vec<String>>,
1635) {
1636 for (key, values) in incoming {
1637 merge_unique_strings(target.entry(key).or_default(), values);
1638 }
1639}
1640
1641fn derive_plural_variable(placeholders: &BTreeMap<String, Vec<String>>) -> Option<String> {
1642 if placeholders.contains_key("count") {
1643 return Some("count".to_owned());
1644 }
1645
1646 let mut named = placeholders
1647 .keys()
1648 .filter(|key| !key.chars().all(|ch| ch.is_ascii_digit()))
1649 .cloned();
1650 let first = named.next()?;
1651 if named.next().is_none() {
1652 Some(first)
1653 } else {
1654 None
1655 }
1656}
1657
1658fn synthesize_icu_plural(variable: &str, branches: &BTreeMap<String, String>) -> String {
1659 let mut out = String::new();
1660 out.push('{');
1661 out.push_str(variable);
1662 out.push_str(", plural,");
1663 for (category, value) in branches {
1664 out.push(' ');
1665 out.push_str(category);
1666 out.push_str(" {");
1667 out.push_str(value);
1668 out.push('}');
1669 }
1670 out.push('}');
1671 out
1672}
1673
1674fn project_icu_plural(input: &str) -> IcuPluralProjection {
1675 if !looks_like_icu_message(input.as_bytes()) {
1676 return IcuPluralProjection::NotPlural;
1677 }
1678
1679 let message = match parse_icu(input) {
1680 Ok(message) => message,
1681 Err(_) => return IcuPluralProjection::Malformed,
1682 };
1683
1684 let Some(IcuNode::Plural {
1685 name,
1686 kind: IcuPluralKind::Cardinal,
1687 offset,
1688 options,
1689 }) = only_node(&message)
1690 else {
1691 return IcuPluralProjection::NotPlural;
1692 };
1693
1694 if *offset != 0 {
1695 return IcuPluralProjection::Unsupported(
1696 "ICU plural offset syntax is not projected into the current catalog plural model.",
1697 );
1698 }
1699
1700 let mut branches = BTreeMap::new();
1701 for option in options {
1702 if option.selector.starts_with('=') {
1703 return IcuPluralProjection::Unsupported(
1704 "ICU exact-match plural selectors are not projected into the current catalog plural model.",
1705 );
1706 }
1707
1708 let value = match render_projectable_icu_nodes(&option.value) {
1709 Ok(value) => value,
1710 Err(message) => return IcuPluralProjection::Unsupported(message),
1711 };
1712 branches.insert(option.selector.clone(), value);
1713 }
1714
1715 if !branches.contains_key("other") {
1716 return IcuPluralProjection::Malformed;
1717 }
1718
1719 IcuPluralProjection::Projected(ParsedIcuPlural {
1720 variable: name.clone(),
1721 branches,
1722 })
1723}
1724
1725#[inline]
1726fn looks_like_icu_message(input: &[u8]) -> bool {
1727 input.iter().any(|byte| matches!(byte, b'{' | b'}' | b'<'))
1728}
1729
1730fn only_node(message: &IcuMessage) -> Option<&IcuNode> {
1731 match message.nodes.as_slice() {
1732 [node] => Some(node),
1733 _ => None,
1734 }
1735}
1736
1737fn render_projectable_icu_nodes(nodes: &[IcuNode]) -> Result<String, &'static str> {
1738 let mut out = String::new();
1739 for node in nodes {
1740 render_projectable_icu_node(node, &mut out)?;
1741 }
1742 Ok(out)
1743}
1744
1745fn render_projectable_icu_node(node: &IcuNode, out: &mut String) -> Result<(), &'static str> {
1746 match node {
1747 IcuNode::Literal(value) => append_escaped_icu_literal(out, value),
1748 IcuNode::Argument { name } => {
1749 out.push('{');
1750 out.push_str(name);
1751 out.push('}');
1752 }
1753 IcuNode::Number { name, style } => render_formatter("number", name, style.as_deref(), out),
1754 IcuNode::Date { name, style } => render_formatter("date", name, style.as_deref(), out),
1755 IcuNode::Time { name, style } => render_formatter("time", name, style.as_deref(), out),
1756 IcuNode::List { name, style } => render_formatter("list", name, style.as_deref(), out),
1757 IcuNode::Duration { name, style } => {
1758 render_formatter("duration", name, style.as_deref(), out)
1759 }
1760 IcuNode::Ago { name, style } => render_formatter("ago", name, style.as_deref(), out),
1761 IcuNode::Name { name, style } => render_formatter("name", name, style.as_deref(), out),
1762 IcuNode::Pound => out.push('#'),
1763 IcuNode::Tag { name, children } => {
1764 out.push('<');
1765 out.push_str(name);
1766 out.push('>');
1767 for child in children {
1768 render_projectable_icu_node(child, out)?;
1769 }
1770 out.push_str("</");
1771 out.push_str(name);
1772 out.push('>');
1773 }
1774 IcuNode::Select { .. } | IcuNode::Plural { .. } => {
1775 return Err(
1776 "Nested ICU select/plural structures are not projected into the current catalog plural model.",
1777 );
1778 }
1779 }
1780
1781 Ok(())
1782}
1783
1784fn render_formatter(kind: &str, name: &str, style: Option<&str>, out: &mut String) {
1785 out.push('{');
1786 out.push_str(name);
1787 out.push_str(", ");
1788 out.push_str(kind);
1789 if let Some(style) = style {
1790 out.push_str(", ");
1791 out.push_str(style);
1792 }
1793 out.push('}');
1794}
1795
1796fn append_escaped_icu_literal(out: &mut String, value: &str) {
1797 if !value
1798 .as_bytes()
1799 .iter()
1800 .any(|byte| matches!(byte, b'\'' | b'{' | b'}' | b'#' | b'<' | b'>'))
1801 {
1802 out.push_str(value);
1803 return;
1804 }
1805
1806 for ch in value.chars() {
1807 match ch {
1808 '\'' => out.push_str("''"),
1809 '{' | '}' | '#' | '<' | '>' => {
1810 out.push('\'');
1811 out.push(ch);
1812 out.push('\'');
1813 }
1814 _ => out.push(ch),
1815 }
1816 }
1817}
1818
1819fn atomic_write(path: &Path, content: &str) -> Result<(), ApiError> {
1820 let directory = path.parent().unwrap_or_else(|| Path::new("."));
1821 fs::create_dir_all(directory)?;
1822
1823 let file_name = path
1824 .file_name()
1825 .and_then(|name| name.to_str())
1826 .ok_or_else(|| {
1827 ApiError::InvalidArguments("target_path must have a file name".to_owned())
1828 })?;
1829 let temp_path = directory.join(format!(".{file_name}.ferrocat.tmp"));
1830 fs::write(&temp_path, content)?;
1831 fs::rename(&temp_path, path)?;
1832 Ok(())
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837 use super::{
1838 ApiError, DiagnosticSeverity, ExtractedMessage, ExtractedPluralMessage,
1839 ExtractedSingularMessage, ObsoleteStrategy, ParseCatalogOptions, PluralEncoding,
1840 PluralSource, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
1841 parse_catalog, update_catalog, update_catalog_file,
1842 };
1843 use crate::parse_po;
1844 use std::collections::BTreeMap;
1845 use std::fs;
1846
1847 #[test]
1848 fn update_catalog_creates_new_source_locale_messages() {
1849 let result = update_catalog(UpdateCatalogOptions {
1850 source_locale: "en".to_owned(),
1851 locale: Some("en".to_owned()),
1852 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
1853 msgid: "Hello".to_owned(),
1854 ..ExtractedSingularMessage::default()
1855 })],
1856 ..UpdateCatalogOptions::default()
1857 })
1858 .expect("update");
1859
1860 let parsed = parse_po(&result.content).expect("parse output");
1861 assert_eq!(parsed.items.len(), 1);
1862 assert_eq!(parsed.items[0].msgid, "Hello");
1863 assert_eq!(parsed.items[0].msgstr[0], "Hello");
1864 assert!(result.created);
1865 assert!(result.updated);
1866 assert_eq!(result.stats.added, 1);
1867 }
1868
1869 #[test]
1870 fn update_catalog_preserves_non_source_translations() {
1871 let existing = "msgid \"Hello\"\nmsgstr \"Hallo\"\n";
1872 let result = update_catalog(UpdateCatalogOptions {
1873 source_locale: "en".to_owned(),
1874 locale: Some("de".to_owned()),
1875 existing: Some(existing.to_owned()),
1876 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
1877 msgid: "Hello".to_owned(),
1878 ..ExtractedSingularMessage::default()
1879 })],
1880 ..UpdateCatalogOptions::default()
1881 })
1882 .expect("update");
1883
1884 let parsed = parse_po(&result.content).expect("parse output");
1885 assert_eq!(parsed.items[0].msgstr[0], "Hallo");
1886 assert_eq!(result.stats.unchanged, 1);
1887 }
1888
1889 #[test]
1890 fn overwrite_source_translations_refreshes_source_locale() {
1891 let existing = "msgid \"Hello\"\nmsgstr \"Old\"\n";
1892 let result = update_catalog(UpdateCatalogOptions {
1893 source_locale: "en".to_owned(),
1894 locale: Some("en".to_owned()),
1895 existing: Some(existing.to_owned()),
1896 overwrite_source_translations: true,
1897 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
1898 msgid: "Hello".to_owned(),
1899 ..ExtractedSingularMessage::default()
1900 })],
1901 ..UpdateCatalogOptions::default()
1902 })
1903 .expect("update");
1904
1905 let parsed = parse_po(&result.content).expect("parse output");
1906 assert_eq!(parsed.items[0].msgstr[0], "Hello");
1907 assert_eq!(result.stats.changed, 1);
1908 }
1909
1910 #[test]
1911 fn obsolete_strategy_delete_removes_missing_messages() {
1912 let existing = "msgid \"keep\"\nmsgstr \"x\"\n\nmsgid \"drop\"\nmsgstr \"y\"\n";
1913 let result = update_catalog(UpdateCatalogOptions {
1914 source_locale: "en".to_owned(),
1915 locale: Some("de".to_owned()),
1916 existing: Some(existing.to_owned()),
1917 obsolete_strategy: ObsoleteStrategy::Delete,
1918 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
1919 msgid: "keep".to_owned(),
1920 ..ExtractedSingularMessage::default()
1921 })],
1922 ..UpdateCatalogOptions::default()
1923 })
1924 .expect("update");
1925
1926 let parsed = parse_po(&result.content).expect("parse output");
1927 assert_eq!(parsed.items.len(), 1);
1928 assert_eq!(result.stats.obsolete_removed, 1);
1929 }
1930
1931 #[test]
1932 fn duplicate_conflicts_fail_hard() {
1933 let error = update_catalog(UpdateCatalogOptions {
1934 source_locale: "en".to_owned(),
1935 locale: Some("en".to_owned()),
1936 extracted: vec![
1937 ExtractedMessage::Singular(ExtractedSingularMessage {
1938 msgid: "Hello".to_owned(),
1939 ..ExtractedSingularMessage::default()
1940 }),
1941 ExtractedMessage::Plural(ExtractedPluralMessage {
1942 msgid: "Hello".to_owned(),
1943 source: PluralSource {
1944 one: Some("One".to_owned()),
1945 other: "Many".to_owned(),
1946 },
1947 ..ExtractedPluralMessage::default()
1948 }),
1949 ],
1950 ..UpdateCatalogOptions::default()
1951 })
1952 .expect_err("conflict");
1953
1954 assert!(matches!(error, ApiError::Conflict(_)));
1955 }
1956
1957 #[test]
1958 fn plural_icu_export_uses_structural_input() {
1959 let result = update_catalog(UpdateCatalogOptions {
1960 source_locale: "en".to_owned(),
1961 locale: Some("en".to_owned()),
1962 extracted: vec![ExtractedMessage::Plural(ExtractedPluralMessage {
1963 msgid: "{count, plural, one {# item} other {# items}}".to_owned(),
1964 source: PluralSource {
1965 one: Some("# item".to_owned()),
1966 other: "# items".to_owned(),
1967 },
1968 placeholders: BTreeMap::from([("count".to_owned(), vec!["count".to_owned()])]),
1969 ..ExtractedPluralMessage::default()
1970 })],
1971 ..UpdateCatalogOptions::default()
1972 })
1973 .expect("update");
1974
1975 let parsed = parse_po(&result.content).expect("parse output");
1976 assert!(parsed.items[0].msgid.contains("{count, plural,"));
1977 assert!(parsed.items[0].msgid_plural.is_none());
1978 }
1979
1980 #[test]
1981 fn parse_catalog_projects_gettext_plural_into_structured_shape() {
1982 let parsed = parse_catalog(ParseCatalogOptions {
1983 content: concat!(
1984 "msgid \"book\"\n",
1985 "msgid_plural \"books\"\n",
1986 "msgstr[0] \"Buch\"\n",
1987 "msgstr[1] \"Buecher\"\n",
1988 )
1989 .to_owned(),
1990 locale: Some("de".to_owned()),
1991 source_locale: "en".to_owned(),
1992 plural_encoding: PluralEncoding::Gettext,
1993 strict: false,
1994 })
1995 .expect("parse");
1996
1997 match &parsed.messages[0].translation {
1998 TranslationShape::Plural {
1999 source,
2000 translation,
2001 } => {
2002 assert_eq!(source.one.as_deref(), Some("book"));
2003 assert_eq!(source.other, "books");
2004 assert_eq!(translation.get("one").map(String::as_str), Some("Buch"));
2005 assert_eq!(
2006 translation.get("other").map(String::as_str),
2007 Some("Buecher")
2008 );
2009 }
2010 other => panic!("expected plural translation, got {other:?}"),
2011 }
2012 }
2013
2014 #[test]
2015 fn parse_catalog_uses_icu_plural_categories_for_french_gettext() {
2016 let parsed = parse_catalog(ParseCatalogOptions {
2017 content: concat!(
2018 "msgid \"fichier\"\n",
2019 "msgid_plural \"fichiers\"\n",
2020 "msgstr[0] \"fichier\"\n",
2021 "msgstr[1] \"millions de fichiers\"\n",
2022 "msgstr[2] \"fichiers\"\n",
2023 )
2024 .to_owned(),
2025 locale: Some("fr".to_owned()),
2026 source_locale: "en".to_owned(),
2027 plural_encoding: PluralEncoding::Gettext,
2028 strict: false,
2029 })
2030 .expect("parse");
2031
2032 match &parsed.messages[0].translation {
2033 TranslationShape::Plural { translation, .. } => {
2034 assert_eq!(translation.get("one").map(String::as_str), Some("fichier"));
2035 assert_eq!(
2036 translation.get("many").map(String::as_str),
2037 Some("millions de fichiers")
2038 );
2039 assert_eq!(
2040 translation.get("other").map(String::as_str),
2041 Some("fichiers")
2042 );
2043 }
2044 other => panic!("expected plural translation, got {other:?}"),
2045 }
2046 }
2047
2048 #[test]
2049 fn parse_catalog_prefers_gettext_slot_count_when_it_disagrees_with_locale_categories() {
2050 let parsed = parse_catalog(ParseCatalogOptions {
2051 content: concat!(
2052 "msgid \"\"\n",
2053 "msgstr \"\"\n",
2054 "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
2055 "\n",
2056 "msgid \"livre\"\n",
2057 "msgid_plural \"livres\"\n",
2058 "msgstr[0] \"livre\"\n",
2059 "msgstr[1] \"livres\"\n",
2060 )
2061 .to_owned(),
2062 locale: Some("fr".to_owned()),
2063 source_locale: "en".to_owned(),
2064 plural_encoding: PluralEncoding::Gettext,
2065 strict: false,
2066 })
2067 .expect("parse");
2068
2069 match &parsed.messages[0].translation {
2070 TranslationShape::Plural { translation, .. } => {
2071 assert_eq!(translation.len(), 2);
2072 assert_eq!(translation.get("one").map(String::as_str), Some("livre"));
2073 assert_eq!(translation.get("other").map(String::as_str), Some("livres"));
2074 assert!(translation.get("many").is_none());
2075 }
2076 other => panic!("expected plural translation, got {other:?}"),
2077 }
2078 }
2079
2080 #[test]
2081 fn parse_catalog_reports_plural_forms_locale_mismatch() {
2082 let parsed = parse_catalog(ParseCatalogOptions {
2083 content: concat!(
2084 "msgid \"\"\n",
2085 "msgstr \"\"\n",
2086 "\"Language: fr\\n\"\n",
2087 "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
2088 )
2089 .to_owned(),
2090 locale: Some("fr".to_owned()),
2091 source_locale: "en".to_owned(),
2092 plural_encoding: PluralEncoding::Gettext,
2093 strict: false,
2094 })
2095 .expect("parse");
2096
2097 assert!(
2098 parsed
2099 .diagnostics
2100 .iter()
2101 .any(|diagnostic| diagnostic.code == "plural.nplurals_locale_mismatch")
2102 );
2103 }
2104
2105 #[test]
2106 fn parse_catalog_detects_simple_icu_plural_when_requested() {
2107 let parsed = parse_catalog(ParseCatalogOptions {
2108 content: concat!(
2109 "msgid \"{count, plural, one {# item} other {# items}}\"\n",
2110 "msgstr \"{count, plural, one {# Artikel} other {# Artikel}}\"\n",
2111 )
2112 .to_owned(),
2113 locale: Some("de".to_owned()),
2114 source_locale: "en".to_owned(),
2115 plural_encoding: PluralEncoding::Icu,
2116 strict: false,
2117 })
2118 .expect("parse");
2119
2120 match &parsed.messages[0].translation {
2121 TranslationShape::Plural { translation, .. } => {
2122 assert_eq!(
2123 translation.get("one").map(String::as_str),
2124 Some("# Artikel")
2125 );
2126 assert_eq!(
2127 translation.get("other").map(String::as_str),
2128 Some("# Artikel")
2129 );
2130 }
2131 other => panic!("expected plural translation, got {other:?}"),
2132 }
2133 }
2134
2135 #[test]
2136 fn parse_catalog_warns_and_falls_back_for_unsupported_nested_icu_plural() {
2137 let parsed = parse_catalog(ParseCatalogOptions {
2138 content: concat!(
2139 "msgid \"{count, plural, one {{gender, select, male {He has one item} other {They have one item}}} other {{gender, select, male {He has # items} other {They have # items}}}}\"\n",
2140 "msgstr \"{count, plural, one {{gender, select, male {Er hat einen Artikel} other {Sie haben einen Artikel}}} other {{gender, select, male {Er hat # Artikel} other {Sie haben # Artikel}}}}\"\n",
2141 )
2142 .to_owned(),
2143 locale: Some("de".to_owned()),
2144 source_locale: "en".to_owned(),
2145 plural_encoding: PluralEncoding::Icu,
2146 strict: false,
2147 })
2148 .expect("parse");
2149
2150 assert!(
2151 parsed
2152 .diagnostics
2153 .iter()
2154 .any(|diagnostic| diagnostic.code == "plural.unsupported_icu_projection")
2155 );
2156 assert!(matches!(
2157 parsed.messages[0].translation,
2158 TranslationShape::Singular { .. }
2159 ));
2160 }
2161
2162 #[test]
2163 fn parse_catalog_strict_fails_on_malformed_icu_plural() {
2164 let error = parse_catalog(ParseCatalogOptions {
2165 content: concat!(
2166 "msgid \"{count, plural, one {# item} other {# items}\"\n",
2167 "msgstr \"{count, plural, one {# Artikel} other {# Artikel}}\"\n",
2168 )
2169 .to_owned(),
2170 locale: Some("de".to_owned()),
2171 source_locale: "en".to_owned(),
2172 plural_encoding: PluralEncoding::Icu,
2173 strict: true,
2174 })
2175 .expect_err("strict parse should fail");
2176
2177 match error {
2178 ApiError::Unsupported(message) => assert!(message.contains("strict mode")),
2179 other => panic!("unexpected error: {other:?}"),
2180 }
2181 }
2182
2183 #[test]
2184 fn update_catalog_file_writes_only_when_changed() {
2185 let temp_dir = std::env::temp_dir().join("ferrocat-po-update-file-test");
2186 let _ = fs::remove_dir_all(&temp_dir);
2187 fs::create_dir_all(&temp_dir).expect("create temp dir");
2188 let path = temp_dir.join("messages.po");
2189
2190 let first = update_catalog_file(UpdateCatalogFileOptions {
2191 target_path: path.clone(),
2192 source_locale: "en".to_owned(),
2193 locale: Some("en".to_owned()),
2194 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2195 msgid: "Hello".to_owned(),
2196 ..ExtractedSingularMessage::default()
2197 })],
2198 ..UpdateCatalogFileOptions::default()
2199 })
2200 .expect("first write");
2201 assert!(first.created);
2202
2203 let second = update_catalog_file(UpdateCatalogFileOptions {
2204 target_path: path.clone(),
2205 source_locale: "en".to_owned(),
2206 locale: Some("en".to_owned()),
2207 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2208 msgid: "Hello".to_owned(),
2209 ..ExtractedSingularMessage::default()
2210 })],
2211 ..UpdateCatalogFileOptions::default()
2212 })
2213 .expect("second write");
2214 assert!(!second.created);
2215 assert!(!second.updated);
2216
2217 let _ = fs::remove_dir_all(&temp_dir);
2218 }
2219
2220 #[test]
2221 fn update_catalog_gettext_export_emits_plural_slots() {
2222 let result = update_catalog(UpdateCatalogOptions {
2223 source_locale: "en".to_owned(),
2224 locale: Some("de".to_owned()),
2225 plural_encoding: PluralEncoding::Gettext,
2226 extracted: vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2227 msgid: "books".to_owned(),
2228 source: PluralSource {
2229 one: Some("book".to_owned()),
2230 other: "books".to_owned(),
2231 },
2232 placeholders: BTreeMap::from([("count".to_owned(), vec!["count".to_owned()])]),
2233 ..ExtractedPluralMessage::default()
2234 })],
2235 ..UpdateCatalogOptions::default()
2236 })
2237 .expect("update");
2238
2239 let parsed = parse_po(&result.content).expect("parse output");
2240 assert_eq!(parsed.items[0].msgid, "book");
2241 assert_eq!(parsed.items[0].msgid_plural.as_deref(), Some("books"));
2242 assert_eq!(parsed.items[0].msgstr.len(), 2);
2243 }
2244
2245 #[test]
2246 fn update_catalog_gettext_export_uses_icu_plural_categories_for_french() {
2247 let result = update_catalog(UpdateCatalogOptions {
2248 source_locale: "en".to_owned(),
2249 locale: Some("fr".to_owned()),
2250 plural_encoding: PluralEncoding::Gettext,
2251 extracted: vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2252 msgid: "files".to_owned(),
2253 source: PluralSource {
2254 one: Some("file".to_owned()),
2255 other: "files".to_owned(),
2256 },
2257 placeholders: BTreeMap::from([("count".to_owned(), vec!["count".to_owned()])]),
2258 ..ExtractedPluralMessage::default()
2259 })],
2260 ..UpdateCatalogOptions::default()
2261 })
2262 .expect("update");
2263
2264 let parsed = parse_po(&result.content).expect("parse output");
2265 assert_eq!(parsed.items[0].msgstr.len(), 3);
2266 }
2267
2268 #[test]
2269 fn update_catalog_gettext_sets_safe_plural_forms_header_for_two_form_locale() {
2270 let result = update_catalog(UpdateCatalogOptions {
2271 source_locale: "en".to_owned(),
2272 locale: Some("de".to_owned()),
2273 plural_encoding: PluralEncoding::Gettext,
2274 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2275 msgid: "Hello".to_owned(),
2276 ..ExtractedSingularMessage::default()
2277 })],
2278 ..UpdateCatalogOptions::default()
2279 })
2280 .expect("update");
2281
2282 let parsed = parse_po(&result.content).expect("parse output");
2283 let plural_forms = parsed
2284 .headers
2285 .iter()
2286 .find(|header| header.key == "Plural-Forms")
2287 .map(|header| header.value.as_str());
2288 assert_eq!(plural_forms, Some("nplurals=2; plural=(n != 1);"));
2289 }
2290
2291 #[test]
2292 fn update_catalog_gettext_reports_when_no_safe_plural_forms_header_is_known() {
2293 let result = update_catalog(UpdateCatalogOptions {
2294 source_locale: "en".to_owned(),
2295 locale: Some("fr".to_owned()),
2296 plural_encoding: PluralEncoding::Gettext,
2297 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2298 msgid: "Bonjour".to_owned(),
2299 ..ExtractedSingularMessage::default()
2300 })],
2301 ..UpdateCatalogOptions::default()
2302 })
2303 .expect("update");
2304
2305 assert!(
2306 result
2307 .diagnostics
2308 .iter()
2309 .any(|diagnostic| diagnostic.code == "plural.missing_plural_forms_header")
2310 );
2311 }
2312
2313 #[test]
2314 fn update_catalog_gettext_completes_partial_plural_forms_header_when_safe() {
2315 let result = update_catalog(UpdateCatalogOptions {
2316 source_locale: "en".to_owned(),
2317 locale: Some("de".to_owned()),
2318 plural_encoding: PluralEncoding::Gettext,
2319 existing: Some(
2320 concat!(
2321 "msgid \"\"\n",
2322 "msgstr \"\"\n",
2323 "\"Language: de\\n\"\n",
2324 "\"Plural-Forms: nplurals=2;\\n\"\n",
2325 )
2326 .to_owned(),
2327 ),
2328 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2329 msgid: "Hello".to_owned(),
2330 ..ExtractedSingularMessage::default()
2331 })],
2332 ..UpdateCatalogOptions::default()
2333 })
2334 .expect("update");
2335
2336 assert!(
2337 result
2338 .diagnostics
2339 .iter()
2340 .any(|diagnostic| diagnostic.code == "plural.completed_plural_forms_header")
2341 );
2342
2343 let parsed = parse_po(&result.content).expect("parse output");
2344 let plural_forms = parsed
2345 .headers
2346 .iter()
2347 .find(|header| header.key == "Plural-Forms")
2348 .map(|header| header.value.as_str());
2349 assert_eq!(plural_forms, Some("nplurals=2; plural=(n != 1);"));
2350 }
2351
2352 #[test]
2353 fn update_catalog_gettext_preserves_existing_complete_plural_forms_header() {
2354 let result = update_catalog(UpdateCatalogOptions {
2355 source_locale: "en".to_owned(),
2356 locale: Some("de".to_owned()),
2357 plural_encoding: PluralEncoding::Gettext,
2358 existing: Some(
2359 concat!(
2360 "msgid \"\"\n",
2361 "msgstr \"\"\n",
2362 "\"Language: de\\n\"\n",
2363 "\"Plural-Forms: nplurals=2; plural=(n > 1);\\n\"\n",
2364 )
2365 .to_owned(),
2366 ),
2367 extracted: vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2368 msgid: "Hello".to_owned(),
2369 ..ExtractedSingularMessage::default()
2370 })],
2371 ..UpdateCatalogOptions::default()
2372 })
2373 .expect("update");
2374
2375 assert!(
2376 !result
2377 .diagnostics
2378 .iter()
2379 .any(|diagnostic| diagnostic.code == "plural.completed_plural_forms_header")
2380 );
2381
2382 let parsed = parse_po(&result.content).expect("parse output");
2383 let plural_forms = parsed
2384 .headers
2385 .iter()
2386 .find(|header| header.key == "Plural-Forms")
2387 .map(|header| header.value.as_str());
2388 assert_eq!(plural_forms, Some("nplurals=2; plural=(n > 1);"));
2389 }
2390
2391 #[test]
2392 fn parse_catalog_requires_source_locale() {
2393 let error = parse_catalog(ParseCatalogOptions {
2394 content: String::new(),
2395 source_locale: String::new(),
2396 ..ParseCatalogOptions::default()
2397 })
2398 .expect_err("missing source locale");
2399
2400 match error {
2401 ApiError::InvalidArguments(message) => {
2402 assert!(message.contains("source_locale"));
2403 }
2404 other => panic!("unexpected error: {other:?}"),
2405 }
2406 }
2407
2408 #[test]
2409 fn warnings_use_expected_namespace() {
2410 let mut placeholders = BTreeMap::new();
2411 placeholders.insert("first".to_owned(), vec!["first".to_owned()]);
2412 placeholders.insert("second".to_owned(), vec!["second".to_owned()]);
2413
2414 let result = update_catalog(UpdateCatalogOptions {
2415 source_locale: "en".to_owned(),
2416 locale: Some("de".to_owned()),
2417 extracted: vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2418 msgid: "Developers".to_owned(),
2419 source: PluralSource {
2420 one: Some("Developer".to_owned()),
2421 other: "Developers".to_owned(),
2422 },
2423 placeholders,
2424 ..ExtractedPluralMessage::default()
2425 })],
2426 ..UpdateCatalogOptions::default()
2427 })
2428 .expect("update");
2429
2430 assert!(
2431 result
2432 .diagnostics
2433 .iter()
2434 .any(|diagnostic| diagnostic.code.starts_with("plural."))
2435 );
2436 assert!(result.diagnostics.iter().all(|diagnostic| matches!(
2437 diagnostic.severity,
2438 DiagnosticSeverity::Warning | DiagnosticSeverity::Error | DiagnosticSeverity::Info
2439 )));
2440 }
2441}