use std::collections::BTreeMap;
use std::fs;
use crate::diagnostic_codes;
use super::export::export_catalog_content;
use super::file_io::atomic_write;
use super::helpers::{
dedupe_origins, dedupe_placeholders, dedupe_strings, merge_placeholders, merge_unique_origins,
merge_unique_strings,
};
use super::mt::{
MachineTranslationMetadata, PO_MACHINE_TRANSLATION_KEY, parse_po_machine_translation_metadata,
};
use super::ndjson::parse_catalog_to_internal_ndjson;
use super::plural::{PluralProfile, derive_plural_variable, expected_gettext_nplurals_for_locale};
use super::{
ApiError, CatalogMessage, CatalogMessageExtra, CatalogOrigin, CatalogSemantics, CatalogStats,
CatalogStorageFormat, CatalogUpdateInput, CatalogUpdateResult, Diagnostic, DiagnosticSeverity,
ExtractedMessage, ObsoleteStrategy, OrderBy, ParseCatalogOptions, ParsedCatalog,
PluralEncoding, PluralSource, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
};
use crate::{MsgStr, PoItem, parse_po};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Catalog {
pub(super) locale: Option<String>,
pub(super) headers: BTreeMap<String, String>,
pub(super) file_comments: Vec<String>,
pub(super) file_extracted_comments: Vec<String>,
pub(super) messages: Vec<CanonicalMessage>,
pub(super) diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CanonicalMessage {
pub(super) msgid: String,
pub(super) msgctxt: Option<String>,
pub(super) translation: CanonicalTranslation,
pub(super) comments: Vec<String>,
pub(super) origins: Vec<CatalogOrigin>,
pub(super) placeholders: BTreeMap<String, Vec<String>>,
pub(super) obsolete: bool,
pub(super) machine_translation: Option<MachineTranslationMetadata>,
pub(super) translator_comments: Vec<String>,
pub(super) flags: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CanonicalTranslation {
Singular {
value: String,
},
Plural {
source: PluralSource,
translation_by_category: BTreeMap<String, String>,
variable: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NormalizedMessage {
msgid: String,
msgctxt: Option<String>,
kind: NormalizedKind,
comments: Vec<String>,
origins: Vec<CatalogOrigin>,
placeholders: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum NormalizedKind {
Singular,
Plural {
source: PluralSource,
variable: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct ParsedPluralFormsHeader {
raw: Option<String>,
nplurals: Option<usize>,
plural: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MergeCatalogContext<'a> {
locale: Option<&'a str>,
source_locale: &'a str,
semantics: CatalogSemantics,
overwrite_source_translations: bool,
obsolete_strategy: ObsoleteStrategy,
}
#[expect(
clippy::needless_pass_by_value,
reason = "Public API takes owned option structs so callers can build and move them ergonomically."
)]
pub fn update_catalog(options: UpdateCatalogOptions<'_>) -> Result<CatalogUpdateResult, ApiError> {
super::validate_source_locale(options.source_locale)?;
let created = options.existing.is_none();
let original = options.existing.unwrap_or("");
let existing = match options.existing {
Some(content) if !content.is_empty() => parse_catalog_to_internal(
content,
options.locale,
options.source_locale,
options.mode.semantics(),
options.mode.plural_encoding(),
false,
options.mode.storage_format(),
)?,
Some(_) | None => Catalog {
locale: options.locale.map(str::to_owned),
headers: BTreeMap::new(),
file_comments: Vec::new(),
file_extracted_comments: Vec::new(),
messages: Vec::new(),
diagnostics: Vec::new(),
},
};
let locale = options
.locale
.map(str::to_owned)
.or_else(|| existing.locale.clone())
.or_else(|| existing.headers.get("Language").cloned());
let mut diagnostics = existing.diagnostics.clone();
let normalized = normalize_update_input(&options.input)?;
let merge_context = MergeCatalogContext {
locale: locale.as_deref(),
source_locale: options.source_locale,
semantics: options.mode.semantics(),
overwrite_source_translations: options.overwrite_source_translations,
obsolete_strategy: options.obsolete_strategy,
};
let (mut merged, stats) =
merge_catalogs(existing, &normalized, merge_context, &mut diagnostics);
merged.locale.clone_from(&locale);
apply_storage_defaults(&mut merged, &options, locale.as_deref(), &mut diagnostics)?;
sort_messages(&mut merged.messages, options.render.order_by);
let content = export_catalog_content(&merged, &options, locale.as_deref(), &mut diagnostics)?;
Ok(CatalogUpdateResult {
updated: content != original,
content,
created,
stats,
diagnostics,
})
}
pub fn update_catalog_file(
options: UpdateCatalogFileOptions<'_>,
) -> Result<CatalogUpdateResult, ApiError> {
super::validate_source_locale(options.options.source_locale)?;
if options.target_path.as_os_str().is_empty() {
return Err(ApiError::InvalidArguments(
"target_path must not be empty".to_owned(),
));
}
let existing = match fs::read_to_string(options.target_path) {
Ok(content) => Some(content),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(ApiError::io_with_path(options.target_path, error)),
};
let mut update_options = options.options;
update_options.existing = existing.as_deref();
let result = update_catalog(update_options)?;
if result.created || result.updated {
atomic_write(options.target_path, &result.content)?;
}
Ok(result)
}
#[expect(
clippy::needless_pass_by_value,
reason = "Public API takes owned option structs so callers can build and move them ergonomically."
)]
pub fn parse_catalog(options: ParseCatalogOptions<'_>) -> Result<ParsedCatalog, ApiError> {
super::validate_source_locale(options.source_locale)?;
let catalog = parse_catalog_to_internal(
options.content,
options.locale,
options.source_locale,
options.mode.semantics(),
options.mode.plural_encoding(),
options.strict,
options.mode.storage_format(),
)?;
let messages = catalog
.messages
.into_iter()
.map(public_message_from_canonical)
.collect();
Ok(ParsedCatalog {
locale: catalog.locale,
semantics: options.mode.semantics(),
headers: catalog.headers,
messages,
diagnostics: catalog.diagnostics,
})
}
fn normalize_update_input(input: &CatalogUpdateInput) -> Result<Vec<NormalizedMessage>, ApiError> {
let mut index = BTreeMap::<(String, Option<String>), usize>::new();
let mut normalized = Vec::<NormalizedMessage>::new();
match input {
CatalogUpdateInput::Structured(extracted) => {
for message in extracted {
let (msgid, msgctxt, kind, comments, origins, placeholders) = match message {
ExtractedMessage::Singular(message) => (
message.msgid.clone(),
message.msgctxt.clone(),
NormalizedKind::Singular,
message.comments.clone(),
message.origin.clone(),
message.placeholders.clone(),
),
ExtractedMessage::Plural(message) => (
message.msgid.clone(),
message.msgctxt.clone(),
NormalizedKind::Plural {
source: message.source.clone(),
variable: None,
},
message.comments.clone(),
message.origin.clone(),
message.placeholders.clone(),
),
};
push_normalized_message(
&mut index,
&mut normalized,
NormalizedMessage {
msgid,
msgctxt,
kind,
comments: dedupe_strings(comments),
origins: dedupe_origins(origins),
placeholders: dedupe_placeholders(placeholders),
},
)?;
}
}
CatalogUpdateInput::SourceFirst(messages) => {
for message in messages {
push_normalized_message(
&mut index,
&mut normalized,
NormalizedMessage {
msgid: message.msgid.clone(),
msgctxt: message.msgctxt.clone(),
kind: NormalizedKind::Singular,
comments: dedupe_strings(message.comments.clone()),
origins: dedupe_origins(message.origin.clone()),
placeholders: dedupe_placeholders(message.placeholders.clone()),
},
)?;
}
}
}
Ok(normalized)
}
fn push_normalized_message(
index: &mut BTreeMap<(String, Option<String>), usize>,
normalized: &mut Vec<NormalizedMessage>,
message: NormalizedMessage,
) -> Result<(), ApiError> {
let msgid = message.msgid.clone();
let msgctxt = message.msgctxt.clone();
if msgid.is_empty() {
return Err(ApiError::InvalidArguments(
"extracted msgid must not be empty".to_owned(),
));
}
let key = (msgid.clone(), msgctxt);
if let Some(existing_index) = index.get(&key).copied() {
let existing = &mut normalized[existing_index];
if existing.kind != message.kind {
return Err(ApiError::Conflict(format!(
"conflicting duplicate extracted message for msgid {msgid:?}"
)));
}
merge_unique_strings(&mut existing.comments, message.comments);
merge_unique_origins(&mut existing.origins, message.origins);
merge_placeholders(&mut existing.placeholders, message.placeholders);
} else {
index.insert(key, normalized.len());
normalized.push(message);
}
Ok(())
}
fn merge_catalogs(
existing: Catalog,
normalized: &[NormalizedMessage],
context: MergeCatalogContext<'_>,
diagnostics: &mut Vec<Diagnostic>,
) -> (Catalog, CatalogStats) {
let is_source_locale = context
.locale
.is_none_or(|value| value == context.source_locale);
let mut stats = CatalogStats::default();
let mut existing_index = BTreeMap::<(String, Option<String>), usize>::new();
for (index, message) in existing.messages.iter().enumerate() {
existing_index.insert((message.msgid.clone(), message.msgctxt.clone()), index);
}
let mut matched = vec![false; existing.messages.len()];
let mut messages = Vec::with_capacity(normalized.len() + existing.messages.len());
for next in normalized {
let key = (next.msgid.clone(), next.msgctxt.clone());
let previous = existing_index.get(&key).copied().map(|index| {
matched[index] = true;
existing.messages[index].clone()
});
let merged = merge_message(
previous.as_ref(),
next,
is_source_locale,
context.locale,
context.semantics,
context.overwrite_source_translations,
diagnostics,
);
if previous.is_none() {
stats.added += 1;
} else if previous.as_ref() == Some(&merged) {
stats.unchanged += 1;
} else {
stats.changed += 1;
}
messages.push(merged);
}
for (index, message) in existing.messages.into_iter().enumerate() {
if matched[index] {
continue;
}
match context.obsolete_strategy {
ObsoleteStrategy::Delete => {
stats.obsolete_removed += 1;
}
ObsoleteStrategy::Mark => {
let mut message = message;
if !message.obsolete {
message.obsolete = true;
stats.obsolete_marked += 1;
}
messages.push(message);
}
ObsoleteStrategy::Keep => {
let mut message = message;
message.obsolete = false;
messages.push(message);
}
}
}
stats.total = messages.len();
(
Catalog {
locale: existing.locale,
headers: existing.headers,
file_comments: existing.file_comments,
file_extracted_comments: existing.file_extracted_comments,
messages,
diagnostics: existing.diagnostics,
},
stats,
)
}
fn merge_message(
previous: Option<&CanonicalMessage>,
next: &NormalizedMessage,
is_source_locale: bool,
locale: Option<&str>,
semantics: CatalogSemantics,
overwrite_source_translations: bool,
diagnostics: &mut Vec<Diagnostic>,
) -> CanonicalMessage {
let translation = match (&next.kind, previous) {
(NormalizedKind::Singular, Some(previous))
if matches!(previous.translation, CanonicalTranslation::Singular { .. })
&& !(is_source_locale && overwrite_source_translations) =>
{
previous.translation.clone()
}
(NormalizedKind::Singular, _) => CanonicalTranslation::Singular {
value: if is_source_locale {
next.msgid.clone()
} else {
String::new()
},
},
(NormalizedKind::Plural { source, variable }, previous) => {
let plural_profile = if semantics == CatalogSemantics::GettextCompat {
PluralProfile::for_gettext_locale(locale)
} else {
PluralProfile::for_locale(locale)
};
match previous {
Some(previous)
if matches!(previous.translation, CanonicalTranslation::Plural { .. })
&& !(is_source_locale && overwrite_source_translations) =>
{
match &previous.translation {
CanonicalTranslation::Plural {
translation_by_category,
variable: previous_variable,
..
} => CanonicalTranslation::Plural {
source: source.clone(),
translation_by_category: plural_profile
.materialize_translation(translation_by_category),
variable: variable
.as_deref()
.map_or_else(|| previous_variable.clone(), str::to_owned),
},
CanonicalTranslation::Singular { .. } => unreachable!(),
}
}
_ => {
let variable = variable
.clone()
.or_else(|| previous.and_then(extract_plural_variable))
.or_else(|| derive_plural_variable(&next.placeholders))
.unwrap_or_else(|| {
diagnostics.push(
Diagnostic::new(
DiagnosticSeverity::Warning,
diagnostic_codes::plural::ASSUMED_VARIABLE,
"Unable to determine plural placeholder name, assuming \"count\".",
)
.with_identity(&next.msgid, next.msgctxt.as_deref()),
);
"count".to_owned()
});
CanonicalTranslation::Plural {
source: source.clone(),
translation_by_category: if is_source_locale {
plural_profile.source_locale_translation(source)
} else {
plural_profile.empty_translation()
},
variable,
}
}
}
}
};
let (machine_translation, translator_comments, flags, obsolete) = previous.map_or_else(
|| (None, Vec::new(), Vec::new(), false),
|message| {
(
message.machine_translation.clone(),
message.translator_comments.clone(),
message.flags.clone(),
false,
)
},
);
CanonicalMessage {
msgid: next.msgid.clone(),
msgctxt: next.msgctxt.clone(),
translation,
comments: next.comments.clone(),
origins: next.origins.clone(),
placeholders: next.placeholders.clone(),
obsolete,
machine_translation,
translator_comments,
flags,
}
}
fn extract_plural_variable(message: &CanonicalMessage) -> Option<String> {
match &message.translation {
CanonicalTranslation::Plural { variable, .. } => Some(variable.clone()),
CanonicalTranslation::Singular { .. } => None,
}
}
pub(super) fn apply_header_defaults(
headers: &mut BTreeMap<String, String>,
locale: Option<&str>,
semantics: CatalogSemantics,
diagnostics: &mut Vec<Diagnostic>,
custom: &BTreeMap<String, String>,
) {
headers
.entry("MIME-Version".to_owned())
.or_insert_with(|| "1.0".to_owned());
headers
.entry("Content-Type".to_owned())
.or_insert_with(|| "text/plain; charset=utf-8".to_owned());
headers
.entry("Content-Transfer-Encoding".to_owned())
.or_insert_with(|| "8bit".to_owned());
headers
.entry("X-Generator".to_owned())
.or_insert_with(|| "ferrocat".to_owned());
if let Some(locale) = locale {
headers.insert("Language".to_owned(), locale.to_owned());
}
if semantics == CatalogSemantics::GettextCompat && !custom.contains_key("Plural-Forms") {
let profile = PluralProfile::for_gettext_locale(locale);
let parsed_header = parse_plural_forms_from_headers(headers);
match (parsed_header.raw.as_deref(), profile.gettext_header()) {
(None, Some(header)) => {
headers.insert("Plural-Forms".to_owned(), header);
}
(None, None) => diagnostics.push(Diagnostic::new(
DiagnosticSeverity::Info,
diagnostic_codes::plural::MISSING_PLURAL_FORMS_HEADER,
"No safe default Plural-Forms header is known for this locale; keeping the header unset.",
)),
(Some(_), Some(header))
if parsed_header.nplurals == Some(profile.nplurals())
&& parsed_header.plural.is_none() =>
{
headers.insert("Plural-Forms".to_owned(), header);
diagnostics.push(Diagnostic::new(
DiagnosticSeverity::Info,
diagnostic_codes::plural::COMPLETED_PLURAL_FORMS_HEADER,
"Plural-Forms header was missing the plural expression and has been completed using a safe locale default.",
));
}
_ => {}
}
}
for (key, value) in custom {
headers.insert(key.clone(), value.clone());
}
}
pub(super) fn sort_messages(messages: &mut [CanonicalMessage], order_by: OrderBy) {
match order_by {
OrderBy::Msgid => messages.sort_by(|left, right| {
left.msgid
.cmp(&right.msgid)
.then_with(|| left.msgctxt.cmp(&right.msgctxt))
.then_with(|| left.obsolete.cmp(&right.obsolete))
}),
OrderBy::Origin => messages.sort_by(|left, right| {
first_origin_sort_key(&left.origins)
.cmp(&first_origin_sort_key(&right.origins))
.then_with(|| left.msgid.cmp(&right.msgid))
.then_with(|| left.msgctxt.cmp(&right.msgctxt))
}),
}
}
fn first_origin_sort_key(origins: &[CatalogOrigin]) -> (String, Option<u32>) {
origins.first().map_or_else(
|| (String::new(), None),
|origin| (origin.file.clone(), origin.line),
)
}
fn apply_storage_defaults(
catalog: &mut Catalog,
options: &UpdateCatalogOptions<'_>,
locale: Option<&str>,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<(), ApiError> {
match options.mode.storage_format() {
CatalogStorageFormat::Po => {
let empty_custom_headers = BTreeMap::new();
apply_header_defaults(
&mut catalog.headers,
locale,
options.mode.semantics(),
diagnostics,
options
.render
.custom_header_attributes
.unwrap_or(&empty_custom_headers),
);
Ok(())
}
CatalogStorageFormat::Ndjson => {
if options
.render
.custom_header_attributes
.is_some_and(|headers| !headers.is_empty())
{
return Err(ApiError::Unsupported(
"custom_header_attributes are not supported for NDJSON catalogs".to_owned(),
));
}
catalog.headers.clear();
Ok(())
}
}
}
pub(super) fn parse_catalog_to_internal(
content: &str,
locale_override: Option<&str>,
source_locale: &str,
semantics: CatalogSemantics,
plural_encoding: PluralEncoding,
strict: bool,
storage_format: CatalogStorageFormat,
) -> Result<Catalog, ApiError> {
match storage_format {
CatalogStorageFormat::Po => parse_catalog_to_internal_po(
content,
locale_override,
semantics,
plural_encoding,
strict,
),
CatalogStorageFormat::Ndjson => parse_catalog_to_internal_ndjson(
content,
locale_override,
source_locale,
semantics,
strict,
),
}
}
fn parse_catalog_to_internal_po(
content: &str,
locale_override: Option<&str>,
semantics: CatalogSemantics,
_plural_encoding: PluralEncoding,
strict: bool,
) -> Result<Catalog, ApiError> {
let file = parse_po(content)?;
let headers = file
.headers
.iter()
.map(|header| (header.key.clone(), header.value.clone()))
.collect::<BTreeMap<_, _>>();
let locale = locale_override
.map(str::to_owned)
.or_else(|| headers.get("Language").cloned());
let plural_forms = parse_plural_forms_from_headers(&headers);
let nplurals = plural_forms.nplurals;
let mut diagnostics = Vec::new();
validate_plural_forms_header(
locale.as_deref(),
&plural_forms,
semantics,
&mut diagnostics,
);
let mut messages = Vec::with_capacity(file.items.len());
for item in file.items {
let mut conversion_diagnostics = Vec::new();
let message = import_message_from_po(
item,
locale.as_deref(),
nplurals,
semantics,
strict,
&mut conversion_diagnostics,
)?;
diagnostics.extend(conversion_diagnostics);
messages.push(message);
}
Ok(Catalog {
locale,
headers,
file_comments: file.comments,
file_extracted_comments: file.extracted_comments,
messages,
diagnostics,
})
}
fn import_message_from_po(
item: PoItem,
locale: Option<&str>,
nplurals: Option<usize>,
semantics: CatalogSemantics,
_strict: bool,
_diagnostics: &mut Vec<Diagnostic>,
) -> Result<CanonicalMessage, ApiError> {
let (comments, placeholders) = split_placeholder_comments(item.extracted_comments);
let origins = item
.references
.iter()
.map(|reference| parse_origin(reference))
.collect();
let translation = if let Some(msgid_plural) = &item.msgid_plural {
if semantics == CatalogSemantics::IcuNative {
return Err(ApiError::Unsupported(
"classic gettext plural requires compat mode".to_owned(),
));
}
let plural_profile =
PluralProfile::for_gettext_slots(locale, nplurals.or(Some(item.msgstr.len())));
CanonicalTranslation::Plural {
source: PluralSource {
one: Some(item.msgid.clone()),
other: msgid_plural.clone(),
},
translation_by_category: plural_profile
.categories()
.iter()
.zip(
item.msgstr
.iter()
.map(String::as_str)
.chain(std::iter::repeat("")),
)
.map(|(category, value)| (category.clone(), value.to_owned()))
.collect(),
variable: "count".to_owned(),
}
} else {
if semantics == CatalogSemantics::IcuNative && matches!(item.msgstr, MsgStr::Plural(_)) {
return Err(ApiError::Unsupported(
"classic gettext plural requires compat mode".to_owned(),
));
}
CanonicalTranslation::Singular {
value: item.msgstr.first_str().unwrap_or_default().to_owned(),
}
};
Ok(CanonicalMessage {
msgid: item.msgid,
msgctxt: item.msgctxt,
translation,
comments,
origins,
placeholders,
obsolete: item.obsolete,
machine_translation: import_machine_translation_metadata(&item.metadata)?,
translator_comments: item.comments,
flags: item.flags,
})
}
fn import_machine_translation_metadata(
metadata: &[(String, String)],
) -> Result<Option<MachineTranslationMetadata>, ApiError> {
let mut value = None;
for (key, next_value) in metadata {
if key != PO_MACHINE_TRANSLATION_KEY {
continue;
}
if value.replace(next_value).is_some() {
return Err(ApiError::InvalidArguments(
"duplicate machine translation metadata for PO item".to_owned(),
));
}
}
value
.map(|value| parse_po_machine_translation_metadata(value))
.transpose()
}
pub(super) fn split_placeholder_comments(
extracted_comments: Vec<String>,
) -> (Vec<String>, BTreeMap<String, Vec<String>>) {
let mut comments = Vec::new();
let mut placeholders = BTreeMap::<String, Vec<String>>::new();
for comment in extracted_comments {
if let Some((name, value)) = parse_placeholder_comment(&comment) {
placeholders.entry(name).or_default().push(value);
} else {
comments.push(comment);
}
}
(comments, dedupe_placeholders(placeholders))
}
fn parse_placeholder_comment(comment: &str) -> Option<(String, String)> {
let rest = comment.strip_prefix("placeholder {")?;
let end = rest.find("}: ")?;
Some((rest[..end].to_owned(), rest[end + 3..].to_owned()))
}
fn parse_origin(reference: &str) -> CatalogOrigin {
match reference.rsplit_once(':') {
Some((file, line)) if line.chars().all(|ch| ch.is_ascii_digit()) => CatalogOrigin {
file: file.to_owned(),
line: line.parse::<u32>().ok(),
},
_ => CatalogOrigin {
file: reference.to_owned(),
line: None,
},
}
}
fn parse_plural_forms_from_headers(headers: &BTreeMap<String, String>) -> ParsedPluralFormsHeader {
let Some(plural_forms) = headers.get("Plural-Forms") else {
return ParsedPluralFormsHeader::default();
};
let mut parsed = ParsedPluralFormsHeader {
raw: Some(plural_forms.clone()),
..ParsedPluralFormsHeader::default()
};
for part in plural_forms.split(';') {
let trimmed = part.trim();
if let Some(value) = trimmed.strip_prefix("nplurals=") {
parsed.nplurals = value.trim().parse().ok();
} else if let Some(value) = trimmed.strip_prefix("plural=") {
let value = value.trim();
if !value.is_empty() {
parsed.plural = Some(value.to_owned());
}
}
}
parsed
}
fn validate_plural_forms_header(
locale: Option<&str>,
plural_forms: &ParsedPluralFormsHeader,
semantics: CatalogSemantics,
diagnostics: &mut Vec<Diagnostic>,
) {
if semantics != CatalogSemantics::GettextCompat {
return;
}
if let Some(nplurals) = plural_forms.nplurals {
match expected_gettext_nplurals_for_locale(locale) {
Some(expected) if nplurals != expected => diagnostics.push(Diagnostic::new(
DiagnosticSeverity::Warning,
diagnostic_codes::plural::NPLURALS_LOCALE_MISMATCH,
format!(
"Plural-Forms declares nplurals={nplurals}, but locale-derived categories expect {expected}."
),
)),
_ => {}
}
} else if plural_forms.plural.is_some() {
diagnostics.push(Diagnostic::new(
DiagnosticSeverity::Warning,
diagnostic_codes::parse::INVALID_PLURAL_FORMS_HEADER,
"Plural-Forms header contains a plural expression but no parseable nplurals value.",
));
}
if plural_forms.nplurals.is_some() && plural_forms.plural.is_none() {
diagnostics.push(Diagnostic::new(
DiagnosticSeverity::Info,
diagnostic_codes::plural::MISSING_PLURAL_EXPRESSION,
"Plural-Forms header declares nplurals but omits the plural expression.",
));
}
}
pub(super) fn public_message_from_canonical(message: CanonicalMessage) -> CatalogMessage {
let translation = match message.translation {
CanonicalTranslation::Singular { value } => TranslationShape::Singular { value },
CanonicalTranslation::Plural {
source,
translation_by_category,
variable,
..
} => TranslationShape::Plural {
source,
translation: translation_by_category,
variable,
},
};
CatalogMessage {
msgid: message.msgid,
msgctxt: message.msgctxt,
translation,
comments: message.comments,
origin: message.origins,
obsolete: message.obsolete,
machine_translation: message.machine_translation,
extra: Some(CatalogMessageExtra {
translator_comments: message.translator_comments,
flags: message.flags,
}),
}
}