Skip to main content

ferrocat_po/api/
catalog.rs

1//! Internal catalog pipeline for the public `ferrocat-po` catalog API.
2//!
3//! This module owns the higher-level workflow around PO parsing, extracted-message
4//! normalization, merge semantics, and export back to PO. The byte-oriented parser
5//! and serializer hot paths stay elsewhere; this layer is where we preserve
6//! catalog semantics and diagnostics.
7
8use 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/// Merges extracted messages into an existing catalog and returns updated catalog content.
104///
105/// # Errors
106///
107/// Returns [`ApiError`] when the source locale is missing, the existing catalog
108/// cannot be parsed, or the requested storage format cannot be rendered safely.
109///
110/// # Examples
111///
112/// ```rust
113/// use ferrocat_po::{
114///     CatalogMode, CatalogUpdateInput, SourceExtractedMessage, UpdateCatalogOptions, update_catalog,
115/// };
116///
117/// let result = update_catalog(UpdateCatalogOptions {
118///     locale: Some("de"),
119///     input: CatalogUpdateInput::SourceFirst(vec![SourceExtractedMessage {
120///         msgid: "Checkout".to_owned(),
121///         ..SourceExtractedMessage::default()
122///     }]),
123///     ..UpdateCatalogOptions::new("en", CatalogUpdateInput::default())
124/// })?;
125///
126/// assert!(result.created);
127/// assert!(result.content.contains("msgid \"Checkout\""));
128/// # Ok::<(), ferrocat_po::ApiError>(())
129/// ```
130#[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
189/// Updates a catalog on disk and only writes the file when the rendered
190/// output changes.
191///
192/// # Errors
193///
194/// Returns [`ApiError`] when the input is invalid, when the existing file
195/// cannot be read or parsed, or when the updated content cannot be written.
196pub 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/// Parses catalog content into the higher-level representation used by
224/// `ferrocat`'s catalog APIs.
225///
226/// # Errors
227///
228/// Returns [`ApiError`] when the catalog content cannot be parsed, the source
229/// locale is missing, or strict ICU projection fails.
230///
231/// # Examples
232///
233/// ```rust
234/// use ferrocat_po::{ParseCatalogOptions, parse_catalog};
235///
236/// let catalog = parse_catalog(ParseCatalogOptions {
237///     locale: Some("de"),
238///     ..ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"Zur Kasse\"\n", "en")
239/// })?;
240///
241/// assert_eq!(catalog.locale.as_deref(), Some("de"));
242/// assert_eq!(catalog.messages.len(), 1);
243/// # Ok::<(), ferrocat_po::ApiError>(())
244/// ```
245#[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
275/// Collapses the accepted extractor input shapes into one merge-oriented form.
276///
277/// The result keeps only the fields that matter for catalog identity and merge
278/// semantics, while also projecting source-first ICU plurals into the same
279/// structured plural representation used by `CatalogUpdateInput::Structured`.
280fn 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
344/// Inserts one normalized message, merging duplicate extractor entries that
345/// refer to the same gettext identity.
346///
347/// Duplicate singular/plural shape mismatches remain a hard error because they
348/// would otherwise make the final catalog shape ambiguous.
349fn 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
381/// Applies extracted messages onto an existing canonical catalog and records the
382/// coarse-grained update counters used by the high-level API.
383fn 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
465/// Resolves the final canonical message for one gettext identity.
466///
467/// This is the central place where source-locale overwrite rules, plural
468/// variable inference, and locale-aware plural category materialization meet.
469fn 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
584/// Fills in the standard catalog headers and only synthesizes `Plural-Forms`
585/// when we have a conservative, locale-safe default.
586pub(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
700/// Parses catalog text into the canonical internal catalog representation used by
701/// both `parse_catalog` and `update_catalog`.
702///
703/// Keeping this internal representation stable lets the public APIs share one
704/// import path before they diverge into normalized lookup or update/export work.
705pub(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
783/// Converts one parsed `PoItem` into the canonical internal message form.
784///
785/// The branching is intentionally centralized here so that gettext plural slot
786/// import, ICU projection, and all associated diagnostics stay in one semantic
787/// decision point.
788fn 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
873/// Splits extractor-style placeholder comments back out of the generic
874/// extracted-comment list during PO import.
875pub(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
892/// Parses the internal placeholder comment format emitted by `append_placeholder_comments`.
893fn 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
899/// Parses a gettext reference while tolerating plain paths and `path:line`.
900fn 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
913/// Extracts the small `Plural-Forms` subset that Ferrocat needs for diagnostics
914/// and gettext-slot interpretation.
915fn 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
939/// Validates only the invariants that materially affect Ferrocat's plural
940/// interpretation, keeping the diagnostics focused on actionable mismatches.
941fn 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
979/// Rebuilds the public `CatalogMessage` shape from the canonical internal form.
980pub(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}