use std::collections::{BTreeMap, HashMap};
use std::sync::{Mutex, OnceLock};
use ferrocat_icu::{IcuMessage, IcuNode, IcuPluralKind, parse_icu};
use icu_locale::Locale;
use icu_plurals::{PluralCategory, PluralRules};
use memchr::memchr;
use super::PluralSource;
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ParsedIcuPlural {
pub(super) variable: String,
pub(super) branches: BTreeMap<String, String>,
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
pub(super) enum IcuPluralProjection {
NotPlural,
Projected(ParsedIcuPlural),
Unsupported(&'static str),
Malformed,
}
pub(super) type PluralCategoryCache = Mutex<HashMap<String, Option<Vec<String>>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PluralProfile {
categories: Vec<String>,
gettext_header: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GettextPluralRule {
locale: &'static str,
categories: &'static [&'static str],
header: &'static str,
}
impl GettextPluralRule {
const fn nplurals(self) -> usize {
self.categories.len()
}
}
const ONE_FORM_CATEGORIES: &[&str] = &["other"];
const TWO_FORM_CATEGORIES: &[&str] = &["one", "other"];
const THREE_FORM_CATEGORIES: &[&str] = &["one", "few", "other"];
const SIX_FORM_CATEGORIES: &[&str] = &["zero", "one", "two", "few", "many", "other"];
const GETTEXT_ONE_FORM_HEADER: &str = "nplurals=1; plural=0;";
const GETTEXT_ONE_ONLY_HEADER: &str = "nplurals=2; plural=(n != 1);";
const GETTEXT_ZERO_ONE_HEADER: &str = "nplurals=2; plural=(n > 1);";
const GETTEXT_POLISH_HEADER: &str = "nplurals=3; plural=(n == 1 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) ? 1 : 2);";
const GETTEXT_SLAVIC_THREE_FORM_HEADER: &str = "nplurals=3; plural=(n % 10 == 1 && n % 100 != 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) ? 1 : 2);";
const GETTEXT_CZECH_SLOVAK_HEADER: &str =
"nplurals=3; plural=(n == 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2);";
const GETTEXT_ARABIC_HEADER: &str = "nplurals=6; plural=(n == 0 ? 0 : n == 1 ? 1 : n == 2 ? 2 : (n % 100 >= 3 && n % 100 <= 10) ? 3 : (n % 100 >= 11) ? 4 : 5);";
const GETTEXT_PLURAL_RULES: &[GettextPluralRule] = &[
GettextPluralRule {
locale: "ar",
categories: SIX_FORM_CATEGORIES,
header: GETTEXT_ARABIC_HEADER,
},
GettextPluralRule {
locale: "be",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_SLAVIC_THREE_FORM_HEADER,
},
GettextPluralRule {
locale: "bg",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "cs",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_CZECH_SLOVAK_HEADER,
},
GettextPluralRule {
locale: "da",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "de",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "el",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "en",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "eo",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "es",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "et",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "fi",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "fr",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ZERO_ONE_HEADER,
},
GettextPluralRule {
locale: "he",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "hr",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_SLAVIC_THREE_FORM_HEADER,
},
GettextPluralRule {
locale: "hu",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "id",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "it",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "ja",
categories: ONE_FORM_CATEGORIES,
header: GETTEXT_ONE_FORM_HEADER,
},
GettextPluralRule {
locale: "ko",
categories: ONE_FORM_CATEGORIES,
header: GETTEXT_ONE_FORM_HEADER,
},
GettextPluralRule {
locale: "nb",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "nl",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "nn",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "no",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "pl",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_POLISH_HEADER,
},
GettextPluralRule {
locale: "pt",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "pt-br",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ZERO_ONE_HEADER,
},
GettextPluralRule {
locale: "ru",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_SLAVIC_THREE_FORM_HEADER,
},
GettextPluralRule {
locale: "sk",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_CZECH_SLOVAK_HEADER,
},
GettextPluralRule {
locale: "sr",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_SLAVIC_THREE_FORM_HEADER,
},
GettextPluralRule {
locale: "sv",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "th",
categories: ONE_FORM_CATEGORIES,
header: GETTEXT_ONE_FORM_HEADER,
},
GettextPluralRule {
locale: "tr",
categories: TWO_FORM_CATEGORIES,
header: GETTEXT_ONE_ONLY_HEADER,
},
GettextPluralRule {
locale: "uk",
categories: THREE_FORM_CATEGORIES,
header: GETTEXT_SLAVIC_THREE_FORM_HEADER,
},
GettextPluralRule {
locale: "vi",
categories: ONE_FORM_CATEGORIES,
header: GETTEXT_ONE_FORM_HEADER,
},
GettextPluralRule {
locale: "zh",
categories: ONE_FORM_CATEGORIES,
header: GETTEXT_ONE_FORM_HEADER,
},
];
impl PluralProfile {
fn new(locale: Option<&str>, nplurals: Option<usize>) -> Self {
let normalized_locale = normalized_locale(locale);
let categories = normalized_locale
.as_deref()
.and_then(icu_plural_categories_for)
.map_or_else(
|| fallback_plural_categories(nplurals),
|locale_categories| {
if nplurals.is_none() || nplurals == Some(locale_categories.len()) {
locale_categories
} else {
fallback_plural_categories(nplurals)
}
},
);
let gettext_header =
gettext_header_for_categories(normalized_locale.as_deref(), categories.len());
Self {
categories,
gettext_header,
}
}
pub(super) fn for_locale(locale: Option<&str>) -> Self {
Self::new(locale, None)
}
pub(super) fn for_gettext_slots(locale: Option<&str>, nplurals: Option<usize>) -> Self {
Self::new_gettext(locale, nplurals)
}
pub(super) fn for_gettext_locale(locale: Option<&str>) -> Self {
Self::new_gettext(locale, None)
}
pub(super) fn for_gettext_translation(
locale: Option<&str>,
translation_by_category: &BTreeMap<String, String>,
) -> Self {
Self::new_gettext(locale, Some(translation_by_category.len()))
}
fn new_gettext(locale: Option<&str>, nplurals: Option<usize>) -> Self {
let normalized_locale = normalized_locale(locale);
if let Some(rule) = normalized_locale
.as_deref()
.and_then(gettext_plural_rule_for_normalized)
.filter(|rule| nplurals.is_none() || nplurals == Some(rule.nplurals()))
{
return Self {
categories: static_categories(rule.categories),
gettext_header: Some(rule.header),
};
}
let categories = normalized_locale
.as_deref()
.and_then(icu_plural_categories_for)
.map_or_else(
|| fallback_plural_categories(nplurals),
|locale_categories| {
if nplurals.is_none() || nplurals == Some(locale_categories.len()) {
locale_categories
} else {
fallback_plural_categories(nplurals)
}
},
);
let gettext_header = if normalized_locale.is_none() {
generic_gettext_header_for_nplurals(categories.len())
} else {
None
};
Self {
categories,
gettext_header,
}
}
pub(super) fn categories(&self) -> &[String] {
&self.categories
}
pub(super) fn nplurals(&self) -> usize {
self.categories.len().max(1)
}
pub(super) fn materialize_translation(
&self,
translation: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
self.categories
.iter()
.map(|category| {
(
category.clone(),
translation.get(category).cloned().unwrap_or_default(),
)
})
.collect()
}
pub(super) fn source_locale_translation(
&self,
source: &PluralSource,
) -> BTreeMap<String, String> {
let mut translation = BTreeMap::new();
for category in &self.categories {
translation.insert(category.clone(), self.source_locale_value(category, source));
}
translation
}
pub(super) fn source_locale_value(&self, category: &str, source: &PluralSource) -> String {
match category {
"one" => source.one.clone().unwrap_or_else(|| source.other.clone()),
_ => source.other.clone(),
}
}
pub(super) fn empty_translation(&self) -> BTreeMap<String, String> {
self.categories
.iter()
.map(|category| (category.clone(), String::new()))
.collect()
}
pub(super) fn gettext_values(&self, translation: &BTreeMap<String, String>) -> Vec<String> {
self.categories
.iter()
.map(|category| translation.get(category).cloned().unwrap_or_default())
.collect()
}
pub(super) fn gettext_header(&self) -> Option<String> {
self.gettext_header.map(str::to_owned)
}
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
pub(super) fn materialize_plural_categories(
categories: &[String],
translation: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
categories
.iter()
.map(|category| {
(
category.clone(),
translation.get(category).cloned().unwrap_or_default(),
)
})
.collect()
}
pub(super) fn icu_plural_categories_for(locale: &str) -> Option<Vec<String>> {
static CACHE: OnceLock<PluralCategoryCache> = OnceLock::new();
cached_icu_plural_categories_for(locale, CACHE.get_or_init(|| Mutex::new(HashMap::new())))
}
pub(super) fn cached_icu_plural_categories_for(
locale: &str,
cache: &PluralCategoryCache,
) -> Option<Vec<String>> {
let normalized = normalize_plural_locale(locale);
if normalized.is_empty() {
return None;
}
let cached = match cache.lock() {
Ok(guard) => guard.get(&normalized).cloned(),
Err(poisoned) => poisoned.into_inner().get(&normalized).cloned(),
};
if let Some(cached) = cached {
return cached;
}
let resolved = normalized
.parse::<Locale>()
.ok()
.and_then(|locale| PluralRules::try_new_cardinal(locale.into()).ok())
.map(|rules| {
rules
.categories()
.map(plural_category_name)
.map(str::to_owned)
.collect::<Vec<_>>()
});
match cache.lock() {
Ok(mut guard) => {
guard.insert(normalized, resolved.clone());
}
Err(poisoned) => {
poisoned.into_inner().insert(normalized, resolved.clone());
}
}
resolved
}
fn normalize_plural_locale(locale: &str) -> String {
locale.trim().replace('_', "-")
}
fn normalized_locale(locale: Option<&str>) -> Option<String> {
let normalized = normalize_plural_locale(locale?);
(!normalized.is_empty()).then_some(normalized)
}
fn static_categories(categories: &[&str]) -> Vec<String> {
categories.iter().copied().map(str::to_owned).collect()
}
fn gettext_plural_rule_for_normalized(locale: &str) -> Option<GettextPluralRule> {
let normalized = locale.to_ascii_lowercase();
gettext_plural_rule_for_key(&normalized).or_else(|| {
normalized
.split_once('-')
.and_then(|(language, _)| gettext_plural_rule_for_key(language))
})
}
fn gettext_plural_rule_for_key(locale: &str) -> Option<GettextPluralRule> {
GETTEXT_PLURAL_RULES
.iter()
.copied()
.find(|rule| rule.locale == locale)
}
fn gettext_header_for_categories(locale: Option<&str>, nplurals: usize) -> Option<&'static str> {
locale
.and_then(gettext_plural_rule_for_normalized)
.filter(|rule| rule.nplurals() == nplurals)
.map(|rule| rule.header)
.or_else(|| {
locale
.is_none()
.then(|| generic_gettext_header_for_nplurals(nplurals))
.flatten()
})
}
fn generic_gettext_header_for_nplurals(nplurals: usize) -> Option<&'static str> {
match nplurals {
1 => Some(GETTEXT_ONE_FORM_HEADER),
2 => Some(GETTEXT_ONE_ONLY_HEADER),
_ => None,
}
}
pub(super) fn expected_gettext_nplurals_for_locale(locale: Option<&str>) -> Option<usize> {
let normalized = normalized_locale(locale)?;
gettext_plural_rule_for_normalized(&normalized)
.map(GettextPluralRule::nplurals)
.or_else(|| icu_plural_categories_for(&normalized).map(|categories| categories.len()))
}
const fn plural_category_name(category: PluralCategory) -> &'static str {
match category {
PluralCategory::Zero => "zero",
PluralCategory::One => "one",
PluralCategory::Two => "two",
PluralCategory::Few => "few",
PluralCategory::Many => "many",
PluralCategory::Other => "other",
}
}
pub(super) fn fallback_plural_categories(nplurals: Option<usize>) -> Vec<String> {
let categories = match nplurals.unwrap_or(2) {
0 | 1 => vec!["other"],
2 => vec!["one", "other"],
3 => vec!["one", "few", "other"],
4 => vec!["one", "few", "many", "other"],
5 => vec!["zero", "one", "few", "many", "other"],
_ => vec!["zero", "one", "two", "few", "many", "other"],
};
categories.into_iter().map(str::to_owned).collect()
}
pub(super) fn sorted_plural_keys(map: &BTreeMap<String, String>) -> Vec<String> {
let mut keys: Vec<_> = map.keys().cloned().collect();
keys.sort_by_key(|key| plural_key_rank(key));
if !keys.iter().any(|key| key == "other") {
keys.push("other".to_owned());
}
keys
}
fn plural_key_rank(key: &str) -> usize {
match key {
"zero" => 0,
"one" => 1,
"two" => 2,
"few" => 3,
"many" => 4,
"other" => 5,
_ => 6,
}
}
pub(super) fn derive_plural_variable(
placeholders: &BTreeMap<String, Vec<String>>,
) -> Option<String> {
if placeholders.contains_key("count") {
return Some("count".to_owned());
}
let mut named = placeholders
.keys()
.filter(|key| !key.chars().all(|ch| ch.is_ascii_digit()))
.cloned();
let first = named.next()?;
if named.next().is_none() {
Some(first)
} else {
None
}
}
pub(super) fn synthesize_icu_plural(variable: &str, branches: &BTreeMap<String, String>) -> String {
let mut out = String::new();
out.push('{');
out.push_str(variable);
out.push_str(", plural,");
for category in sorted_plural_keys(branches) {
let value = branches.get(&category).map_or("", String::as_str);
out.push(' ');
out.push_str(&category);
out.push_str(" {");
out.push_str(value);
out.push('}');
}
out.push('}');
out
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
pub(super) fn project_icu_plural(input: &str) -> IcuPluralProjection {
if !looks_like_projectable_icu_plural(input.as_bytes()) {
return IcuPluralProjection::NotPlural;
}
let Ok(message) = parse_icu(input) else {
return IcuPluralProjection::Malformed;
};
let Some(IcuNode::Plural {
name,
kind: IcuPluralKind::Cardinal,
offset,
options,
}) = only_node(&message)
else {
return IcuPluralProjection::NotPlural;
};
if *offset != 0 {
return IcuPluralProjection::Unsupported(
"ICU plural offset syntax is not projected into the current catalog plural model.",
);
}
let mut branches = BTreeMap::new();
for option in options {
if option.selector.starts_with('=') {
return IcuPluralProjection::Unsupported(
"ICU exact-match plural selectors are not projected into the current catalog plural model.",
);
}
let value = match render_projectable_icu_nodes(&option.value) {
Ok(value) => value,
Err(message) => return IcuPluralProjection::Unsupported(message),
};
branches.insert(option.selector.clone(), value);
}
if !branches.contains_key("other") {
return IcuPluralProjection::Malformed;
}
IcuPluralProjection::Projected(ParsedIcuPlural {
variable: name.clone(),
branches,
})
}
#[inline]
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn looks_like_projectable_icu_plural(input: &[u8]) -> bool {
let input = trim_ascii(input);
let Some(first) = input.first().copied() else {
return false;
};
match first {
b'<' => return false,
b'{' => {}
_ => return false,
}
let Some(after_open) = input.get(1..) else {
return false;
};
let Some(first_comma) = memchr(b',', after_open) else {
return false;
};
if first_comma == 0 {
return true;
}
let after_name = trim_ascii_start(&after_open[first_comma + 1..]);
let Some((kind, _tail)) = split_icu_kind(after_name) else {
return true;
};
if kind == b"plural" {
return true;
}
!matches!(
kind,
b"number"
| b"date"
| b"time"
| b"list"
| b"duration"
| b"ago"
| b"name"
| b"select"
| b"selectordinal"
)
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn trim_ascii(input: &[u8]) -> &[u8] {
trim_ascii_end(trim_ascii_start(input))
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn trim_ascii_start(input: &[u8]) -> &[u8] {
let start = input
.iter()
.position(|byte| !byte.is_ascii_whitespace())
.unwrap_or(input.len());
&input[start..]
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn trim_ascii_end(input: &[u8]) -> &[u8] {
let end = input
.iter()
.rposition(|byte| !byte.is_ascii_whitespace())
.map(|index| index + 1)
.unwrap_or(0);
&input[..end]
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn split_icu_kind(input: &[u8]) -> Option<(&[u8], &[u8])> {
let token_end = input
.iter()
.position(|byte| byte.is_ascii_whitespace() || matches!(byte, b',' | b'}'))
.unwrap_or(input.len());
let kind = input.get(..token_end)?;
if kind.is_empty() {
return None;
}
Some((kind, input.get(token_end..).unwrap_or_default()))
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn only_node(message: &IcuMessage) -> Option<&IcuNode> {
match message.nodes.as_slice() {
[node] => Some(node),
_ => None,
}
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn render_projectable_icu_nodes(nodes: &[IcuNode]) -> Result<String, &'static str> {
let mut out = String::new();
for node in nodes {
render_projectable_icu_node(node, &mut out)?;
}
Ok(out)
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn render_projectable_icu_node(node: &IcuNode, out: &mut String) -> Result<(), &'static str> {
match node {
IcuNode::Literal(value) => append_escaped_icu_literal(out, value),
IcuNode::Argument { name } => {
out.push('{');
out.push_str(name);
out.push('}');
}
IcuNode::Number { name, style } => render_formatter("number", name, style.as_deref(), out),
IcuNode::Date { name, style } => render_formatter("date", name, style.as_deref(), out),
IcuNode::Time { name, style } => render_formatter("time", name, style.as_deref(), out),
IcuNode::List { name, style } => render_formatter("list", name, style.as_deref(), out),
IcuNode::Duration { name, style } => {
render_formatter("duration", name, style.as_deref(), out);
}
IcuNode::Ago { name, style } => render_formatter("ago", name, style.as_deref(), out),
IcuNode::Name { name, style } => render_formatter("name", name, style.as_deref(), out),
IcuNode::Pound => out.push('#'),
IcuNode::Tag {
name,
children,
self_closing,
} => {
out.push('<');
out.push_str(name);
if *self_closing {
out.push_str("/>");
} else {
out.push('>');
for child in children {
render_projectable_icu_node(child, out)?;
}
out.push_str("</");
out.push_str(name);
out.push('>');
}
}
IcuNode::Select { .. } | IcuNode::Plural { .. } => {
return Err(
"Nested ICU select/plural structures are not projected into the current catalog plural model.",
);
}
#[allow(
unreachable_patterns,
reason = "cargo package verifies ferrocat-po against the latest published ferrocat-icu until this release publishes the dependency crate first."
)]
_ => {
return Err(
"Unsupported ICU node is not projected into the current catalog plural model.",
);
}
}
Ok(())
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn render_formatter(kind: &str, name: &str, style: Option<&str>, out: &mut String) {
out.push('{');
out.push_str(name);
out.push_str(", ");
out.push_str(kind);
if let Some(style) = style {
out.push_str(", ");
out.push_str(style);
}
out.push('}');
}
#[allow(
dead_code,
reason = "ICU projection remains available for lazy/on-demand bridges."
)]
fn append_escaped_icu_literal(out: &mut String, value: &str) {
if !value
.as_bytes()
.iter()
.any(|byte| matches!(byte, b'\'' | b'{' | b'}' | b'#' | b'<' | b'>'))
{
out.push_str(value);
return;
}
for ch in value.chars() {
match ch {
'\'' => out.push_str("''"),
'{' | '}' | '#' | '<' | '>' => {
out.push('\'');
out.push(ch);
out.push('\'');
}
_ => out.push(ch),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};
use std::sync::Mutex;
use super::{
GETTEXT_ARABIC_HEADER, GETTEXT_ONE_FORM_HEADER, GETTEXT_POLISH_HEADER,
GETTEXT_SLAVIC_THREE_FORM_HEADER, GETTEXT_ZERO_ONE_HEADER, IcuPluralProjection,
PluralProfile, cached_icu_plural_categories_for, derive_plural_variable,
expected_gettext_nplurals_for_locale, fallback_plural_categories,
looks_like_projectable_icu_plural, materialize_plural_categories, normalize_plural_locale,
project_icu_plural, sorted_plural_keys, split_icu_kind, synthesize_icu_plural,
};
#[test]
fn plural_fast_scan_skips_plain_and_mixed_messages() {
assert!(!looks_like_projectable_icu_plural(
b"Bench 1: Hello {name}, you have {count} items."
));
assert!(!looks_like_projectable_icu_plural(
b"<link>{name}</link> updated benchmark entry."
));
assert!(!looks_like_projectable_icu_plural(
b"{count, number, integer}"
));
assert!(!looks_like_projectable_icu_plural(b"{name}"));
}
#[test]
fn plural_fast_scan_keeps_plural_candidates() {
assert!(looks_like_projectable_icu_plural(
b"{count, plural, one {# item} other {# items}}"
));
assert!(looks_like_projectable_icu_plural(
b"{count,plural,one {# item} other {# items}}"
));
assert!(looks_like_projectable_icu_plural(
b"{count, plural one {# item} other {# items}}"
));
assert!(looks_like_projectable_icu_plural(b"{count, plura"));
}
#[test]
fn project_icu_plural_keeps_formatter_messages_singular() {
assert!(matches!(
project_icu_plural("Bench 1: {count, number, integer} items for {name}."),
IcuPluralProjection::NotPlural
));
assert!(matches!(
project_icu_plural("<link>{name}</link> updated benchmark entry."),
IcuPluralProjection::NotPlural
));
assert!(matches!(
project_icu_plural("{count, number, integer}"),
IcuPluralProjection::NotPlural
));
}
#[test]
fn plural_profiles_and_category_helpers_fill_expected_shapes() {
let profile = PluralProfile::for_gettext_translation(
Some("fr"),
&BTreeMap::from([
("one".to_owned(), "un".to_owned()),
("other".to_owned(), "autres".to_owned()),
]),
);
assert_eq!(profile.nplurals(), 2);
assert_eq!(
profile.categories(),
&["one".to_owned(), "other".to_owned()]
);
assert_eq!(
profile.materialize_translation(&BTreeMap::from([(
"other".to_owned(),
"autres".to_owned(),
)])),
BTreeMap::from([
("one".to_owned(), String::new()),
("other".to_owned(), "autres".to_owned()),
])
);
assert_eq!(
profile.source_locale_translation(&super::PluralSource {
one: Some("one-file".to_owned()),
other: "many-files".to_owned(),
}),
BTreeMap::from([
("one".to_owned(), "one-file".to_owned()),
("other".to_owned(), "many-files".to_owned()),
])
);
assert_eq!(
profile.empty_translation(),
BTreeMap::from([
("one".to_owned(), String::new()),
("other".to_owned(), String::new()),
])
);
assert_eq!(
profile.gettext_values(&BTreeMap::from([
("one".to_owned(), "eins".to_owned()),
("other".to_owned(), "viele".to_owned()),
])),
vec!["eins".to_owned(), "viele".to_owned()]
);
assert_eq!(
profile.gettext_header().as_deref(),
Some(GETTEXT_ZERO_ONE_HEADER)
);
assert_eq!(
materialize_plural_categories(
&["one".to_owned(), "other".to_owned()],
&BTreeMap::from([("one".to_owned(), "eins".to_owned())]),
),
BTreeMap::from([
("one".to_owned(), "eins".to_owned()),
("other".to_owned(), String::new()),
])
);
}
#[test]
fn gettext_plural_profiles_use_safe_locale_table() {
let cases = [
("fr", GETTEXT_ZERO_ONE_HEADER, &["one", "other"][..]),
("pt_BR", GETTEXT_ZERO_ONE_HEADER, &["one", "other"]),
("pl", GETTEXT_POLISH_HEADER, &["one", "few", "other"]),
(
"ru",
GETTEXT_SLAVIC_THREE_FORM_HEADER,
&["one", "few", "other"],
),
(
"ar",
GETTEXT_ARABIC_HEADER,
&["zero", "one", "two", "few", "many", "other"],
),
("ja", GETTEXT_ONE_FORM_HEADER, &["other"]),
];
for (locale, header, categories) in cases {
let profile = PluralProfile::for_gettext_locale(Some(locale));
let expected_categories = static_test_categories(categories);
assert_eq!(profile.gettext_header().as_deref(), Some(header));
assert_eq!(profile.categories(), expected_categories);
assert_eq!(
expected_gettext_nplurals_for_locale(Some(locale)),
Some(categories.len())
);
}
}
#[test]
fn gettext_plural_profiles_do_not_guess_headers_for_unlisted_locales() {
let profile = PluralProfile::for_gettext_locale(Some("ga"));
assert_eq!(profile.gettext_header(), None);
}
#[test]
fn plural_category_fallbacks_and_sorting_are_deterministic() {
assert_eq!(
fallback_plural_categories(Some(1)),
vec!["other".to_owned()]
);
assert_eq!(
fallback_plural_categories(Some(3)),
vec!["one".to_owned(), "few".to_owned(), "other".to_owned()]
);
assert_eq!(
fallback_plural_categories(Some(7)),
vec![
"zero".to_owned(),
"one".to_owned(),
"two".to_owned(),
"few".to_owned(),
"many".to_owned(),
"other".to_owned(),
]
);
assert_eq!(
sorted_plural_keys(&BTreeMap::from([
("many".to_owned(), "viele".to_owned()),
("one".to_owned(), "eins".to_owned()),
])),
vec!["one".to_owned(), "many".to_owned(), "other".to_owned()]
);
}
#[test]
fn derive_plural_variable_prefers_count_and_rejects_ambiguous_sets() {
assert_eq!(
derive_plural_variable(&BTreeMap::from([
("count".to_owned(), vec!["1".to_owned()]),
("items".to_owned(), vec!["files".to_owned()]),
])),
Some("count".to_owned())
);
assert_eq!(
derive_plural_variable(&BTreeMap::from([(
"items".to_owned(),
vec!["files".to_owned()],
)])),
Some("items".to_owned())
);
assert_eq!(
derive_plural_variable(&BTreeMap::from([
("1".to_owned(), vec!["eins".to_owned()]),
("2".to_owned(), vec!["zwei".to_owned()]),
])),
None
);
assert_eq!(
derive_plural_variable(&BTreeMap::from([
("files".to_owned(), vec!["many".to_owned()]),
("items".to_owned(), vec!["many".to_owned()]),
])),
None
);
}
#[test]
fn synthesize_and_project_icu_plural_cover_supported_and_unsupported_cases() {
let synthesized = synthesize_icu_plural(
"count",
&BTreeMap::from([
("other".to_owned(), "# files".to_owned()),
("one".to_owned(), "# file".to_owned()),
]),
);
assert_eq!(synthesized, "{count, plural, one {# file} other {# files}}");
assert!(matches!(
project_icu_plural("{count, plural, offset:1 one {# file} other {# files}}"),
IcuPluralProjection::Unsupported(message) if message.contains("offset")
));
assert!(matches!(
project_icu_plural("{count, plural, =0 {none} other {# files}}"),
IcuPluralProjection::Unsupported(message) if message.contains("exact-match")
));
assert!(matches!(
project_icu_plural("{count, plural, one {{gender, select, other {# file}}} other {# files}}"),
IcuPluralProjection::Unsupported(message) if message.contains("Nested ICU")
));
assert!(matches!(
project_icu_plural("{count, plural, one {# file}}"),
IcuPluralProjection::Malformed
));
assert!(matches!(
project_icu_plural("plain text"),
IcuPluralProjection::NotPlural
));
}
#[test]
fn project_icu_plural_renders_supported_nested_leaf_nodes() {
let projection = project_icu_plural(
"{count, plural, one {It''s '{'one'}' <b>{name}</b><0/> {price, number, integer} {created, date, short} {time, time, HH:mm} {items, list, conjunction} {elapsed, duration} {since, ago} {person, name}} other {# files}}",
);
match projection {
IcuPluralProjection::Projected(parsed) => {
assert_eq!(parsed.variable, "count");
assert_eq!(
parsed.branches.get("one").map(String::as_str),
Some(
"It''s '{'one'}' <b>{name}</b><0/> {price, number, integer} {created, date, short} {time, time, HH:mm} {items, list, conjunction} {elapsed, duration} {since, ago} {person, name}"
)
);
assert_eq!(
parsed.branches.get("other").map(String::as_str),
Some("# files")
);
}
_ => panic!("expected projected plural"),
}
}
#[test]
fn project_icu_plural_reports_malformed_and_non_plural_candidates() {
assert!(matches!(
project_icu_plural("{count, plural, one {# file} other {# files}"),
IcuPluralProjection::Malformed
));
assert!(matches!(
project_icu_plural("{rank, selectordinal, one {#st} other {#th}}"),
IcuPluralProjection::NotPlural
));
assert!(!looks_like_projectable_icu_plural(b""));
assert!(!looks_like_projectable_icu_plural(b"{"));
assert!(looks_like_projectable_icu_plural(b"{, plural"));
assert!(looks_like_projectable_icu_plural(b"{count,"));
assert!(looks_like_projectable_icu_plural(b"{count, unknown"));
}
#[test]
fn locale_normalization_and_cache_helpers_cover_hits_and_misses() {
assert_eq!(normalize_plural_locale(" pt_BR "), "pt-BR");
assert_eq!(
split_icu_kind(b"plural, one {x}").map(|(kind, _)| kind),
Some(&b"plural"[..])
);
assert_eq!(split_icu_kind(b"").map(|(kind, _)| kind), None);
let cache = Mutex::new(HashMap::new());
assert_eq!(cached_icu_plural_categories_for(" ", &cache), None);
assert!(
cached_icu_plural_categories_for("de", &cache)
.expect("categories")
.iter()
.any(|category| category == "other")
);
let synthetic = PluralProfile::for_gettext_slots(Some("fr"), Some(4));
assert_eq!(
synthetic.categories(),
&[
"one".to_owned(),
"few".to_owned(),
"many".to_owned(),
"other".to_owned()
]
);
assert_eq!(
PluralProfile::for_gettext_slots(Some("und"), Some(1)).nplurals(),
1
);
}
fn static_test_categories(categories: &[&str]) -> Vec<String> {
categories.iter().copied().map(str::to_owned).collect()
}
}