1use std::collections::BTreeMap;
9use std::fs;
10
11use crate::diagnostic_codes;
12
13use super::export::export_catalog_content;
14use super::file_io::atomic_write;
15use super::helpers::{
16 dedupe_origins, dedupe_placeholders, dedupe_strings, merge_placeholders, merge_unique_origins,
17 merge_unique_strings,
18};
19use super::mt::{
20 MachineTranslationMetadata, PO_MACHINE_TRANSLATION_KEY, parse_po_machine_translation_metadata,
21};
22use super::ndjson::parse_catalog_to_internal_ndjson;
23use super::plural::{PluralProfile, derive_plural_variable, expected_gettext_nplurals_for_locale};
24use super::{
25 ApiError, CatalogMessage, CatalogMessageExtra, CatalogOrigin, CatalogSemantics, CatalogStats,
26 CatalogStorageFormat, CatalogUpdateInput, CatalogUpdateResult, Diagnostic, DiagnosticSeverity,
27 ExtractedMessage, ObsoleteStrategy, OrderBy, ParseCatalogOptions, ParsedCatalog,
28 PluralEncoding, PluralSource, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
29};
30use crate::{MsgStr, PoItem, parse_po};
31
32#[derive(Debug, Clone, PartialEq, Eq, Default)]
33pub(super) struct Catalog {
34 pub(super) locale: Option<String>,
35 pub(super) headers: BTreeMap<String, String>,
36 pub(super) file_comments: Vec<String>,
37 pub(super) file_extracted_comments: Vec<String>,
38 pub(super) messages: Vec<CanonicalMessage>,
39 pub(super) diagnostics: Vec<Diagnostic>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(super) struct CanonicalMessage {
44 pub(super) msgid: String,
45 pub(super) msgctxt: Option<String>,
46 pub(super) translation: CanonicalTranslation,
47 pub(super) comments: Vec<String>,
48 pub(super) origins: Vec<CatalogOrigin>,
49 pub(super) placeholders: BTreeMap<String, Vec<String>>,
50 pub(super) obsolete: bool,
51 pub(super) machine_translation: Option<MachineTranslationMetadata>,
52 pub(super) translator_comments: Vec<String>,
53 pub(super) flags: Vec<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub(super) enum CanonicalTranslation {
58 Singular {
59 value: String,
60 },
61 Plural {
62 source: PluralSource,
63 translation_by_category: BTreeMap<String, String>,
64 variable: String,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69struct NormalizedMessage {
70 msgid: String,
71 msgctxt: Option<String>,
72 kind: NormalizedKind,
73 comments: Vec<String>,
74 origins: Vec<CatalogOrigin>,
75 placeholders: BTreeMap<String, Vec<String>>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79enum NormalizedKind {
80 Singular,
81 Plural {
82 source: PluralSource,
83 variable: Option<String>,
84 },
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Default)]
88struct ParsedPluralFormsHeader {
89 raw: Option<String>,
90 nplurals: Option<usize>,
91 plural: Option<String>,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95struct MergeCatalogContext<'a> {
96 locale: Option<&'a str>,
97 source_locale: &'a str,
98 semantics: CatalogSemantics,
99 overwrite_source_translations: bool,
100 obsolete_strategy: ObsoleteStrategy,
101}
102
103#[expect(
131 clippy::needless_pass_by_value,
132 reason = "Public API takes owned option structs so callers can build and move them ergonomically."
133)]
134pub fn update_catalog(options: UpdateCatalogOptions<'_>) -> Result<CatalogUpdateResult, ApiError> {
135 super::validate_source_locale(options.source_locale)?;
136
137 let created = options.existing.is_none();
138 let original = options.existing.unwrap_or("");
139 let existing = match options.existing {
140 Some(content) if !content.is_empty() => parse_catalog_to_internal(
141 content,
142 options.locale,
143 options.source_locale,
144 options.mode.semantics(),
145 options.mode.plural_encoding(),
146 false,
147 options.mode.storage_format(),
148 )?,
149 Some(_) | None => Catalog {
150 locale: options.locale.map(str::to_owned),
151 headers: BTreeMap::new(),
152 file_comments: Vec::new(),
153 file_extracted_comments: Vec::new(),
154 messages: Vec::new(),
155 diagnostics: Vec::new(),
156 },
157 };
158
159 let locale = options
160 .locale
161 .map(str::to_owned)
162 .or_else(|| existing.locale.clone())
163 .or_else(|| existing.headers.get("Language").cloned());
164 let mut diagnostics = existing.diagnostics.clone();
165 let normalized = normalize_update_input(&options.input)?;
166 let merge_context = MergeCatalogContext {
167 locale: locale.as_deref(),
168 source_locale: options.source_locale,
169 semantics: options.mode.semantics(),
170 overwrite_source_translations: options.overwrite_source_translations,
171 obsolete_strategy: options.obsolete_strategy,
172 };
173 let (mut merged, stats) =
174 merge_catalogs(existing, &normalized, merge_context, &mut diagnostics);
175 merged.locale.clone_from(&locale);
176 apply_storage_defaults(&mut merged, &options, locale.as_deref(), &mut diagnostics)?;
177 sort_messages(&mut merged.messages, options.render.order_by);
178 let content = export_catalog_content(&merged, &options, locale.as_deref(), &mut diagnostics)?;
179
180 Ok(CatalogUpdateResult {
181 updated: content != original,
182 content,
183 created,
184 stats,
185 diagnostics,
186 })
187}
188
189pub fn update_catalog_file(
197 options: UpdateCatalogFileOptions<'_>,
198) -> Result<CatalogUpdateResult, ApiError> {
199 super::validate_source_locale(options.options.source_locale)?;
200 if options.target_path.as_os_str().is_empty() {
201 return Err(ApiError::InvalidArguments(
202 "target_path must not be empty".to_owned(),
203 ));
204 }
205
206 let existing = match fs::read_to_string(options.target_path) {
207 Ok(content) => Some(content),
208 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
209 Err(error) => return Err(ApiError::io_with_path(options.target_path, error)),
210 };
211
212 let mut update_options = options.options;
213 update_options.existing = existing.as_deref();
214 let result = update_catalog(update_options)?;
215
216 if result.created || result.updated {
217 atomic_write(options.target_path, &result.content)?;
218 }
219
220 Ok(result)
221}
222
223#[expect(
246 clippy::needless_pass_by_value,
247 reason = "Public API takes owned option structs so callers can build and move them ergonomically."
248)]
249pub fn parse_catalog(options: ParseCatalogOptions<'_>) -> Result<ParsedCatalog, ApiError> {
250 super::validate_source_locale(options.source_locale)?;
251 let catalog = parse_catalog_to_internal(
252 options.content,
253 options.locale,
254 options.source_locale,
255 options.mode.semantics(),
256 options.mode.plural_encoding(),
257 options.strict,
258 options.mode.storage_format(),
259 )?;
260 let messages = catalog
261 .messages
262 .into_iter()
263 .map(public_message_from_canonical)
264 .collect();
265
266 Ok(ParsedCatalog {
267 locale: catalog.locale,
268 semantics: options.mode.semantics(),
269 headers: catalog.headers,
270 messages,
271 diagnostics: catalog.diagnostics,
272 })
273}
274
275fn normalize_update_input(input: &CatalogUpdateInput) -> Result<Vec<NormalizedMessage>, ApiError> {
281 let mut index = BTreeMap::<(String, Option<String>), usize>::new();
282 let mut normalized = Vec::<NormalizedMessage>::new();
283
284 match input {
285 CatalogUpdateInput::Structured(extracted) => {
286 for message in extracted {
287 let (msgid, msgctxt, kind, comments, origins, placeholders) = match message {
288 ExtractedMessage::Singular(message) => (
289 message.msgid.clone(),
290 message.msgctxt.clone(),
291 NormalizedKind::Singular,
292 message.comments.clone(),
293 message.origin.clone(),
294 message.placeholders.clone(),
295 ),
296 ExtractedMessage::Plural(message) => (
297 message.msgid.clone(),
298 message.msgctxt.clone(),
299 NormalizedKind::Plural {
300 source: message.source.clone(),
301 variable: None,
302 },
303 message.comments.clone(),
304 message.origin.clone(),
305 message.placeholders.clone(),
306 ),
307 };
308
309 push_normalized_message(
310 &mut index,
311 &mut normalized,
312 NormalizedMessage {
313 msgid,
314 msgctxt,
315 kind,
316 comments: dedupe_strings(comments),
317 origins: dedupe_origins(origins),
318 placeholders: dedupe_placeholders(placeholders),
319 },
320 )?;
321 }
322 }
323 CatalogUpdateInput::SourceFirst(messages) => {
324 for message in messages {
325 push_normalized_message(
326 &mut index,
327 &mut normalized,
328 NormalizedMessage {
329 msgid: message.msgid.clone(),
330 msgctxt: message.msgctxt.clone(),
331 kind: NormalizedKind::Singular,
332 comments: dedupe_strings(message.comments.clone()),
333 origins: dedupe_origins(message.origin.clone()),
334 placeholders: dedupe_placeholders(message.placeholders.clone()),
335 },
336 )?;
337 }
338 }
339 }
340
341 Ok(normalized)
342}
343
344fn push_normalized_message(
350 index: &mut BTreeMap<(String, Option<String>), usize>,
351 normalized: &mut Vec<NormalizedMessage>,
352 message: NormalizedMessage,
353) -> Result<(), ApiError> {
354 let msgid = message.msgid.clone();
355 let msgctxt = message.msgctxt.clone();
356 if msgid.is_empty() {
357 return Err(ApiError::InvalidArguments(
358 "extracted msgid must not be empty".to_owned(),
359 ));
360 }
361
362 let key = (msgid.clone(), msgctxt);
363 if let Some(existing_index) = index.get(&key).copied() {
364 let existing = &mut normalized[existing_index];
365 if existing.kind != message.kind {
366 return Err(ApiError::Conflict(format!(
367 "conflicting duplicate extracted message for msgid {msgid:?}"
368 )));
369 }
370 merge_unique_strings(&mut existing.comments, message.comments);
371 merge_unique_origins(&mut existing.origins, message.origins);
372 merge_placeholders(&mut existing.placeholders, message.placeholders);
373 } else {
374 index.insert(key, normalized.len());
375 normalized.push(message);
376 }
377
378 Ok(())
379}
380
381fn merge_catalogs(
384 existing: Catalog,
385 normalized: &[NormalizedMessage],
386 context: MergeCatalogContext<'_>,
387 diagnostics: &mut Vec<Diagnostic>,
388) -> (Catalog, CatalogStats) {
389 let is_source_locale = context
390 .locale
391 .is_none_or(|value| value == context.source_locale);
392 let mut stats = CatalogStats::default();
393
394 let mut existing_index = BTreeMap::<(String, Option<String>), usize>::new();
395 for (index, message) in existing.messages.iter().enumerate() {
396 existing_index.insert((message.msgid.clone(), message.msgctxt.clone()), index);
397 }
398
399 let mut matched = vec![false; existing.messages.len()];
400 let mut messages = Vec::with_capacity(normalized.len() + existing.messages.len());
401
402 for next in normalized {
403 let key = (next.msgid.clone(), next.msgctxt.clone());
404 let previous = existing_index.get(&key).copied().map(|index| {
405 matched[index] = true;
406 existing.messages[index].clone()
407 });
408 let merged = merge_message(
409 previous.as_ref(),
410 next,
411 is_source_locale,
412 context.locale,
413 context.semantics,
414 context.overwrite_source_translations,
415 diagnostics,
416 );
417 if previous.is_none() {
418 stats.added += 1;
419 } else if previous.as_ref() == Some(&merged) {
420 stats.unchanged += 1;
421 } else {
422 stats.changed += 1;
423 }
424 messages.push(merged);
425 }
426
427 for (index, message) in existing.messages.into_iter().enumerate() {
428 if matched[index] {
429 continue;
430 }
431 match context.obsolete_strategy {
432 ObsoleteStrategy::Delete => {
433 stats.obsolete_removed += 1;
434 }
435 ObsoleteStrategy::Mark => {
436 let mut message = message;
437 if !message.obsolete {
438 message.obsolete = true;
439 stats.obsolete_marked += 1;
440 }
441 messages.push(message);
442 }
443 ObsoleteStrategy::Keep => {
444 let mut message = message;
445 message.obsolete = false;
446 messages.push(message);
447 }
448 }
449 }
450
451 stats.total = messages.len();
452 (
453 Catalog {
454 locale: existing.locale,
455 headers: existing.headers,
456 file_comments: existing.file_comments,
457 file_extracted_comments: existing.file_extracted_comments,
458 messages,
459 diagnostics: existing.diagnostics,
460 },
461 stats,
462 )
463}
464
465fn merge_message(
470 previous: Option<&CanonicalMessage>,
471 next: &NormalizedMessage,
472 is_source_locale: bool,
473 locale: Option<&str>,
474 semantics: CatalogSemantics,
475 overwrite_source_translations: bool,
476 diagnostics: &mut Vec<Diagnostic>,
477) -> CanonicalMessage {
478 let translation = match (&next.kind, previous) {
479 (NormalizedKind::Singular, Some(previous))
480 if matches!(previous.translation, CanonicalTranslation::Singular { .. })
481 && !(is_source_locale && overwrite_source_translations) =>
482 {
483 previous.translation.clone()
484 }
485 (NormalizedKind::Singular, _) => CanonicalTranslation::Singular {
486 value: if is_source_locale {
487 next.msgid.clone()
488 } else {
489 String::new()
490 },
491 },
492 (NormalizedKind::Plural { source, variable }, previous) => {
493 let plural_profile = if semantics == CatalogSemantics::GettextCompat {
494 PluralProfile::for_gettext_locale(locale)
495 } else {
496 PluralProfile::for_locale(locale)
497 };
498
499 match previous {
500 Some(previous)
501 if matches!(previous.translation, CanonicalTranslation::Plural { .. })
502 && !(is_source_locale && overwrite_source_translations) =>
503 {
504 match &previous.translation {
505 CanonicalTranslation::Plural {
506 translation_by_category,
507 variable: previous_variable,
508 ..
509 } => CanonicalTranslation::Plural {
510 source: source.clone(),
511 translation_by_category: plural_profile
512 .materialize_translation(translation_by_category),
513 variable: variable
514 .as_deref()
515 .map_or_else(|| previous_variable.clone(), str::to_owned),
516 },
517 CanonicalTranslation::Singular { .. } => unreachable!(),
518 }
519 }
520 _ => {
521 let variable = variable
522 .clone()
523 .or_else(|| previous.and_then(extract_plural_variable))
524 .or_else(|| derive_plural_variable(&next.placeholders))
525 .unwrap_or_else(|| {
526 diagnostics.push(
527 Diagnostic::new(
528 DiagnosticSeverity::Warning,
529 diagnostic_codes::plural::ASSUMED_VARIABLE,
530 "Unable to determine plural placeholder name, assuming \"count\".",
531 )
532 .with_identity(&next.msgid, next.msgctxt.as_deref()),
533 );
534 "count".to_owned()
535 });
536
537 CanonicalTranslation::Plural {
538 source: source.clone(),
539 translation_by_category: if is_source_locale {
540 plural_profile.source_locale_translation(source)
541 } else {
542 plural_profile.empty_translation()
543 },
544 variable,
545 }
546 }
547 }
548 }
549 };
550
551 let (machine_translation, translator_comments, flags, obsolete) = previous.map_or_else(
552 || (None, Vec::new(), Vec::new(), false),
553 |message| {
554 (
555 message.machine_translation.clone(),
556 message.translator_comments.clone(),
557 message.flags.clone(),
558 false,
559 )
560 },
561 );
562
563 CanonicalMessage {
564 msgid: next.msgid.clone(),
565 msgctxt: next.msgctxt.clone(),
566 translation,
567 comments: next.comments.clone(),
568 origins: next.origins.clone(),
569 placeholders: next.placeholders.clone(),
570 obsolete,
571 machine_translation,
572 translator_comments,
573 flags,
574 }
575}
576
577fn extract_plural_variable(message: &CanonicalMessage) -> Option<String> {
578 match &message.translation {
579 CanonicalTranslation::Plural { variable, .. } => Some(variable.clone()),
580 CanonicalTranslation::Singular { .. } => None,
581 }
582}
583
584pub(super) fn apply_header_defaults(
587 headers: &mut BTreeMap<String, String>,
588 locale: Option<&str>,
589 semantics: CatalogSemantics,
590 diagnostics: &mut Vec<Diagnostic>,
591 custom: &BTreeMap<String, String>,
592) {
593 headers
594 .entry("MIME-Version".to_owned())
595 .or_insert_with(|| "1.0".to_owned());
596 headers
597 .entry("Content-Type".to_owned())
598 .or_insert_with(|| "text/plain; charset=utf-8".to_owned());
599 headers
600 .entry("Content-Transfer-Encoding".to_owned())
601 .or_insert_with(|| "8bit".to_owned());
602 headers
603 .entry("X-Generator".to_owned())
604 .or_insert_with(|| "ferrocat".to_owned());
605 if let Some(locale) = locale {
606 headers.insert("Language".to_owned(), locale.to_owned());
607 }
608 if semantics == CatalogSemantics::GettextCompat && !custom.contains_key("Plural-Forms") {
609 let profile = PluralProfile::for_gettext_locale(locale);
610 let parsed_header = parse_plural_forms_from_headers(headers);
611 match (parsed_header.raw.as_deref(), profile.gettext_header()) {
612 (None, Some(header)) => {
613 headers.insert("Plural-Forms".to_owned(), header);
614 }
615 (None, None) => diagnostics.push(Diagnostic::new(
616 DiagnosticSeverity::Info,
617 diagnostic_codes::plural::MISSING_PLURAL_FORMS_HEADER,
618 "No safe default Plural-Forms header is known for this locale; keeping the header unset.",
619 )),
620 (Some(_), Some(header))
621 if parsed_header.nplurals == Some(profile.nplurals())
622 && parsed_header.plural.is_none() =>
623 {
624 headers.insert("Plural-Forms".to_owned(), header);
625 diagnostics.push(Diagnostic::new(
626 DiagnosticSeverity::Info,
627 diagnostic_codes::plural::COMPLETED_PLURAL_FORMS_HEADER,
628 "Plural-Forms header was missing the plural expression and has been completed using a safe locale default.",
629 ));
630 }
631 _ => {}
632 }
633 }
634 for (key, value) in custom {
635 headers.insert(key.clone(), value.clone());
636 }
637}
638
639pub(super) fn sort_messages(messages: &mut [CanonicalMessage], order_by: OrderBy) {
640 match order_by {
641 OrderBy::Msgid => messages.sort_by(|left, right| {
642 left.msgid
643 .cmp(&right.msgid)
644 .then_with(|| left.msgctxt.cmp(&right.msgctxt))
645 .then_with(|| left.obsolete.cmp(&right.obsolete))
646 }),
647 OrderBy::Origin => messages.sort_by(|left, right| {
648 first_origin_sort_key(&left.origins)
649 .cmp(&first_origin_sort_key(&right.origins))
650 .then_with(|| left.msgid.cmp(&right.msgid))
651 .then_with(|| left.msgctxt.cmp(&right.msgctxt))
652 }),
653 }
654}
655
656fn first_origin_sort_key(origins: &[CatalogOrigin]) -> (String, Option<u32>) {
657 origins.first().map_or_else(
658 || (String::new(), None),
659 |origin| (origin.file.clone(), origin.line),
660 )
661}
662
663fn apply_storage_defaults(
664 catalog: &mut Catalog,
665 options: &UpdateCatalogOptions<'_>,
666 locale: Option<&str>,
667 diagnostics: &mut Vec<Diagnostic>,
668) -> Result<(), ApiError> {
669 match options.mode.storage_format() {
670 CatalogStorageFormat::Po => {
671 let empty_custom_headers = BTreeMap::new();
672 apply_header_defaults(
673 &mut catalog.headers,
674 locale,
675 options.mode.semantics(),
676 diagnostics,
677 options
678 .render
679 .custom_header_attributes
680 .unwrap_or(&empty_custom_headers),
681 );
682 Ok(())
683 }
684 CatalogStorageFormat::Ndjson => {
685 if options
686 .render
687 .custom_header_attributes
688 .is_some_and(|headers| !headers.is_empty())
689 {
690 return Err(ApiError::Unsupported(
691 "custom_header_attributes are not supported for NDJSON catalogs".to_owned(),
692 ));
693 }
694 catalog.headers.clear();
695 Ok(())
696 }
697 }
698}
699
700pub(super) fn parse_catalog_to_internal(
706 content: &str,
707 locale_override: Option<&str>,
708 source_locale: &str,
709 semantics: CatalogSemantics,
710 plural_encoding: PluralEncoding,
711 strict: bool,
712 storage_format: CatalogStorageFormat,
713) -> Result<Catalog, ApiError> {
714 match storage_format {
715 CatalogStorageFormat::Po => parse_catalog_to_internal_po(
716 content,
717 locale_override,
718 semantics,
719 plural_encoding,
720 strict,
721 ),
722 CatalogStorageFormat::Ndjson => parse_catalog_to_internal_ndjson(
723 content,
724 locale_override,
725 source_locale,
726 semantics,
727 strict,
728 ),
729 }
730}
731
732fn parse_catalog_to_internal_po(
733 content: &str,
734 locale_override: Option<&str>,
735 semantics: CatalogSemantics,
736 _plural_encoding: PluralEncoding,
737 strict: bool,
738) -> Result<Catalog, ApiError> {
739 let file = parse_po(content)?;
740 let headers = file
741 .headers
742 .iter()
743 .map(|header| (header.key.clone(), header.value.clone()))
744 .collect::<BTreeMap<_, _>>();
745 let locale = locale_override
746 .map(str::to_owned)
747 .or_else(|| headers.get("Language").cloned());
748 let plural_forms = parse_plural_forms_from_headers(&headers);
749 let nplurals = plural_forms.nplurals;
750 let mut diagnostics = Vec::new();
751 validate_plural_forms_header(
752 locale.as_deref(),
753 &plural_forms,
754 semantics,
755 &mut diagnostics,
756 );
757 let mut messages = Vec::with_capacity(file.items.len());
758
759 for item in file.items {
760 let mut conversion_diagnostics = Vec::new();
761 let message = import_message_from_po(
762 item,
763 locale.as_deref(),
764 nplurals,
765 semantics,
766 strict,
767 &mut conversion_diagnostics,
768 )?;
769 diagnostics.extend(conversion_diagnostics);
770 messages.push(message);
771 }
772
773 Ok(Catalog {
774 locale,
775 headers,
776 file_comments: file.comments,
777 file_extracted_comments: file.extracted_comments,
778 messages,
779 diagnostics,
780 })
781}
782
783fn import_message_from_po(
789 item: PoItem,
790 locale: Option<&str>,
791 nplurals: Option<usize>,
792 semantics: CatalogSemantics,
793 _strict: bool,
794 _diagnostics: &mut Vec<Diagnostic>,
795) -> Result<CanonicalMessage, ApiError> {
796 let (comments, placeholders) = split_placeholder_comments(item.extracted_comments);
797 let origins = item
798 .references
799 .iter()
800 .map(|reference| parse_origin(reference))
801 .collect();
802
803 let translation = if let Some(msgid_plural) = &item.msgid_plural {
804 if semantics == CatalogSemantics::IcuNative {
805 return Err(ApiError::Unsupported(
806 "classic gettext plural requires compat mode".to_owned(),
807 ));
808 }
809 let plural_profile =
810 PluralProfile::for_gettext_slots(locale, nplurals.or(Some(item.msgstr.len())));
811 CanonicalTranslation::Plural {
812 source: PluralSource {
813 one: Some(item.msgid.clone()),
814 other: msgid_plural.clone(),
815 },
816 translation_by_category: plural_profile
817 .categories()
818 .iter()
819 .zip(
820 item.msgstr
821 .iter()
822 .map(String::as_str)
823 .chain(std::iter::repeat("")),
824 )
825 .map(|(category, value)| (category.clone(), value.to_owned()))
826 .collect(),
827 variable: "count".to_owned(),
828 }
829 } else {
830 if semantics == CatalogSemantics::IcuNative && matches!(item.msgstr, MsgStr::Plural(_)) {
831 return Err(ApiError::Unsupported(
832 "classic gettext plural requires compat mode".to_owned(),
833 ));
834 }
835 CanonicalTranslation::Singular {
836 value: item.msgstr.first_str().unwrap_or_default().to_owned(),
837 }
838 };
839
840 Ok(CanonicalMessage {
841 msgid: item.msgid,
842 msgctxt: item.msgctxt,
843 translation,
844 comments,
845 origins,
846 placeholders,
847 obsolete: item.obsolete,
848 machine_translation: import_machine_translation_metadata(&item.metadata)?,
849 translator_comments: item.comments,
850 flags: item.flags,
851 })
852}
853
854fn import_machine_translation_metadata(
855 metadata: &[(String, String)],
856) -> Result<Option<MachineTranslationMetadata>, ApiError> {
857 let mut value = None;
858 for (key, next_value) in metadata {
859 if key != PO_MACHINE_TRANSLATION_KEY {
860 continue;
861 }
862 if value.replace(next_value).is_some() {
863 return Err(ApiError::InvalidArguments(
864 "duplicate machine translation metadata for PO item".to_owned(),
865 ));
866 }
867 }
868 value
869 .map(|value| parse_po_machine_translation_metadata(value))
870 .transpose()
871}
872
873pub(super) fn split_placeholder_comments(
876 extracted_comments: Vec<String>,
877) -> (Vec<String>, BTreeMap<String, Vec<String>>) {
878 let mut comments = Vec::new();
879 let mut placeholders = BTreeMap::<String, Vec<String>>::new();
880
881 for comment in extracted_comments {
882 if let Some((name, value)) = parse_placeholder_comment(&comment) {
883 placeholders.entry(name).or_default().push(value);
884 } else {
885 comments.push(comment);
886 }
887 }
888
889 (comments, dedupe_placeholders(placeholders))
890}
891
892fn parse_placeholder_comment(comment: &str) -> Option<(String, String)> {
894 let rest = comment.strip_prefix("placeholder {")?;
895 let end = rest.find("}: ")?;
896 Some((rest[..end].to_owned(), rest[end + 3..].to_owned()))
897}
898
899fn parse_origin(reference: &str) -> CatalogOrigin {
901 match reference.rsplit_once(':') {
902 Some((file, line)) if line.chars().all(|ch| ch.is_ascii_digit()) => CatalogOrigin {
903 file: file.to_owned(),
904 line: line.parse::<u32>().ok(),
905 },
906 _ => CatalogOrigin {
907 file: reference.to_owned(),
908 line: None,
909 },
910 }
911}
912
913fn parse_plural_forms_from_headers(headers: &BTreeMap<String, String>) -> ParsedPluralFormsHeader {
916 let Some(plural_forms) = headers.get("Plural-Forms") else {
917 return ParsedPluralFormsHeader::default();
918 };
919
920 let mut parsed = ParsedPluralFormsHeader {
921 raw: Some(plural_forms.clone()),
922 ..ParsedPluralFormsHeader::default()
923 };
924 for part in plural_forms.split(';') {
925 let trimmed = part.trim();
926 if let Some(value) = trimmed.strip_prefix("nplurals=") {
927 parsed.nplurals = value.trim().parse().ok();
928 } else if let Some(value) = trimmed.strip_prefix("plural=") {
929 let value = value.trim();
930 if !value.is_empty() {
931 parsed.plural = Some(value.to_owned());
932 }
933 }
934 }
935
936 parsed
937}
938
939fn validate_plural_forms_header(
942 locale: Option<&str>,
943 plural_forms: &ParsedPluralFormsHeader,
944 semantics: CatalogSemantics,
945 diagnostics: &mut Vec<Diagnostic>,
946) {
947 if semantics != CatalogSemantics::GettextCompat {
948 return;
949 }
950
951 if let Some(nplurals) = plural_forms.nplurals {
952 match expected_gettext_nplurals_for_locale(locale) {
953 Some(expected) if nplurals != expected => diagnostics.push(Diagnostic::new(
954 DiagnosticSeverity::Warning,
955 diagnostic_codes::plural::NPLURALS_LOCALE_MISMATCH,
956 format!(
957 "Plural-Forms declares nplurals={nplurals}, but locale-derived categories expect {expected}."
958 ),
959 )),
960 _ => {}
961 }
962 } else if plural_forms.plural.is_some() {
963 diagnostics.push(Diagnostic::new(
964 DiagnosticSeverity::Warning,
965 diagnostic_codes::parse::INVALID_PLURAL_FORMS_HEADER,
966 "Plural-Forms header contains a plural expression but no parseable nplurals value.",
967 ));
968 }
969
970 if plural_forms.nplurals.is_some() && plural_forms.plural.is_none() {
971 diagnostics.push(Diagnostic::new(
972 DiagnosticSeverity::Info,
973 diagnostic_codes::plural::MISSING_PLURAL_EXPRESSION,
974 "Plural-Forms header declares nplurals but omits the plural expression.",
975 ));
976 }
977}
978
979pub(super) fn public_message_from_canonical(message: CanonicalMessage) -> CatalogMessage {
981 let translation = match message.translation {
982 CanonicalTranslation::Singular { value } => TranslationShape::Singular { value },
983 CanonicalTranslation::Plural {
984 source,
985 translation_by_category,
986 variable,
987 ..
988 } => TranslationShape::Plural {
989 source,
990 translation: translation_by_category,
991 variable,
992 },
993 };
994
995 CatalogMessage {
996 msgid: message.msgid,
997 msgctxt: message.msgctxt,
998 translation,
999 comments: message.comments,
1000 origin: message.origins,
1001 obsolete: message.obsolete,
1002 machine_translation: message.machine_translation,
1003 extra: Some(CatalogMessageExtra {
1004 translator_comments: message.translator_comments,
1005 flags: message.flags,
1006 }),
1007 }
1008}