use std::collections::{BTreeMap, BTreeSet};
use unic_langid::LanguageIdentifier;
use crate::{
error::Error,
types::{EntryStatus, Plural, PluralCategory, Resource, Translation},
};
use lazy_static::lazy_static;
use serde::Serialize;
lazy_static! {
static ref CATEGORY_TABLE: BTreeMap<&'static str, BTreeSet<PluralCategory>> = {
use PluralCategory::*;
let mut m: BTreeMap<&'static str, BTreeSet<PluralCategory>> = BTreeMap::new();
fn s(items: &[PluralCategory]) -> BTreeSet<PluralCategory> {
items.iter().cloned().collect()
}
for code in [
"en","de","nl","sv","da","nb","nn","no","is","fi","et","fa","hi","bn","gu",
"ta","te","kn","ml","mr","it","es","pt","mk","el","eu","gl","af","sw","ur",
"fil","tl","tr","id","ms","fr","hy","kab"
] {
m.insert(code, s(&[One, Other]));
}
for code in ["ja","zh","ko","th","vi","km","lo","my","yue"] {
m.insert(code, s(&[Other]));
}
for code in ["ru","uk","be","sr","hr","bs","sh"] {
m.insert(code, s(&[One, Few, Many, Other]));
}
m.insert("pl", s(&[One, Few, Many, Other]));
for code in ["cs","sk"] {
m.insert(code, s(&[One, Few, Other]));
}
m.insert("sl", s(&[One, Two, Few, Other]));
m.insert("lt", s(&[One, Few, Other]));
m.insert("lv", s(&[Zero, One, Other]));
m.insert("ga", s(&[One, Two, Few, Many, Other]));
m.insert("ro", s(&[One, Few, Other]));
m.insert("ar", s(&[Zero, One, Two, Few, Many, Other]));
for code in ["he","iw"] {
m.insert(code, s(&[One, Two, Many, Other]));
}
m
};
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PluralValidationReport {
pub language: String,
pub key: String,
pub missing: BTreeSet<PluralCategory>,
pub have: BTreeSet<PluralCategory>,
}
pub fn required_categories_for(lang: &LanguageIdentifier) -> BTreeSet<PluralCategory> {
let lang_str = lang.language.as_str();
CATEGORY_TABLE.get(lang_str).cloned().unwrap_or_else(|| {
let mut s = BTreeSet::new();
s.insert(PluralCategory::Other);
s
})
}
pub fn required_categories_for_str(lang: &str) -> BTreeSet<PluralCategory> {
let normalized = lang.replace('_', "-");
let parsed: LanguageIdentifier = normalized
.parse()
.unwrap_or_else(|_| "und".parse().unwrap());
required_categories_for(&parsed)
}
pub fn missing_categories_for_plural(
lang: &LanguageIdentifier,
plural: &Plural,
) -> BTreeSet<PluralCategory> {
let required = required_categories_for(lang);
let have: BTreeSet<PluralCategory> = plural.forms.keys().cloned().collect();
&required - &have
}
pub fn collect_resource_plural_issues(resource: &Resource) -> Vec<PluralValidationReport> {
let Some(lang_id) = resource.parse_language_identifier() else {
return vec![PluralValidationReport {
language: resource.metadata.language.clone(),
key: String::from("<resource>"),
missing: [PluralCategory::Other].into_iter().collect(),
have: BTreeSet::new(),
}];
};
let mut reports = Vec::new();
for entry in &resource.entries {
if let Translation::Plural(plural) = &entry.value {
let have: BTreeSet<PluralCategory> = plural.forms.keys().cloned().collect();
let missing = missing_categories_for_plural(&lang_id, plural);
if !missing.is_empty() {
reports.push(PluralValidationReport {
language: resource.metadata.language.clone(),
key: entry.id.clone(),
missing,
have,
});
}
}
}
reports
}
pub fn validate_resource_plurals(resource: &Resource) -> Result<(), Error> {
let reports = collect_resource_plural_issues(resource);
if reports.is_empty() {
return Ok(());
}
let mut lines = Vec::new();
for r in reports {
let miss: Vec<String> = r.missing.iter().map(|k| format!("{:?}", k)).collect();
let have: Vec<String> = r.have.iter().map(|k| format!("{:?}", k)).collect();
lines.push(format!(
"lang='{}' key='{}': missing plural categories: [{}] (have: [{}])",
r.language,
r.key,
miss.join(", "),
have.join(", ")
));
}
Err(Error::validation_error(format!(
"Plural validation failed:\n{}",
lines.join("\n")
)))
}
pub fn autofix_fill_missing_from_other_resource(resource: &mut Resource) -> usize {
let Some(lang_id) = resource.parse_language_identifier() else {
return 0;
};
let mut added = 0usize;
for entry in &mut resource.entries {
if matches!(entry.status, EntryStatus::DoNotTranslate) {
continue;
}
if let Translation::Plural(plural) = &mut entry.value {
let missing = missing_categories_for_plural(&lang_id, plural);
if missing.is_empty() {
continue;
}
if let Some(other_val) = plural.forms.get(&PluralCategory::Other).cloned() {
let mut added_here = 0usize;
for cat in missing {
if let std::collections::btree_map::Entry::Vacant(e) = plural.forms.entry(cat) {
e.insert(other_val.clone());
added += 1;
added_here += 1;
}
}
if added_here > 0 && !matches!(entry.status, EntryStatus::NeedsReview) {
entry.status = EntryStatus::NeedsReview;
}
}
}
}
added
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Entry, EntryStatus, Metadata};
#[test]
fn test_required_categories_basic() {
let en: LanguageIdentifier = "en".parse().unwrap();
let ru: LanguageIdentifier = "ru".parse().unwrap();
let ja: LanguageIdentifier = "ja".parse().unwrap();
let en_set = required_categories_for(&en);
assert!(en_set.contains(&PluralCategory::One));
assert!(en_set.contains(&PluralCategory::Other));
assert_eq!(en_set.len(), 2);
let ru_set = required_categories_for(&ru);
assert!(ru_set.contains(&PluralCategory::One));
assert!(ru_set.contains(&PluralCategory::Few));
assert!(ru_set.contains(&PluralCategory::Many));
assert!(ru_set.contains(&PluralCategory::Other));
assert_eq!(ru_set.len(), 4);
let ja_set = required_categories_for(&ja);
assert!(ja_set.contains(&PluralCategory::Other));
assert_eq!(ja_set.len(), 1);
}
#[test]
fn test_validate_resource_plurals_missing() {
let resource = Resource {
metadata: Metadata {
language: "en".into(),
domain: String::new(),
custom: Default::default(),
},
entries: vec![Entry {
id: "apples".into(),
value: Translation::Plural(
Plural::new(
"apples",
vec![(PluralCategory::Other, "%d apples".to_string())].into_iter(),
)
.unwrap(),
),
comment: None,
status: EntryStatus::Translated,
custom: Default::default(),
}],
};
let err = validate_resource_plurals(&resource).unwrap_err();
assert!(format!("{}", err).contains("missing plural categories"));
}
#[test]
fn test_collect_resource_plural_issues() {
let resource = Resource {
metadata: Metadata {
language: "en".into(),
domain: String::new(),
custom: Default::default(),
},
entries: vec![Entry {
id: "apples".into(),
value: Translation::Plural(
Plural::new(
"apples",
vec![(PluralCategory::Other, "%d apples".to_string())].into_iter(),
)
.unwrap(),
),
comment: None,
status: EntryStatus::Translated,
custom: Default::default(),
}],
};
let reports = collect_resource_plural_issues(&resource);
assert_eq!(reports.len(), 1);
let r = &reports[0];
assert_eq!(r.language, "en");
assert_eq!(r.key, "apples");
assert!(r.missing.contains(&PluralCategory::One));
assert!(r.have.contains(&PluralCategory::Other));
}
#[test]
fn test_autofix_fill_missing_from_other_resource() {
let mut resource = Resource {
metadata: Metadata {
language: "en".into(),
domain: String::new(),
custom: Default::default(),
},
entries: vec![Entry {
id: "apples".into(),
value: Translation::Plural(
Plural::new(
"apples",
vec![(PluralCategory::Other, "%d apples".to_string())].into_iter(),
)
.unwrap(),
),
comment: None,
status: EntryStatus::Translated,
custom: Default::default(),
}],
};
let added = autofix_fill_missing_from_other_resource(&mut resource);
assert!(added >= 1);
let entry = &resource.entries[0];
if let Translation::Plural(p) = &entry.value {
assert!(p.forms.contains_key(&PluralCategory::One));
assert_eq!(
p.forms.get(&PluralCategory::One).unwrap(),
p.forms.get(&PluralCategory::Other).unwrap()
);
} else {
panic!("expected plural");
}
assert!(matches!(entry.status, EntryStatus::NeedsReview));
}
#[test]
fn test_autofix_does_not_mark_unchanged_entries() {
let mut resource = Resource {
metadata: Metadata {
language: "en".into(),
domain: String::new(),
custom: Default::default(),
},
entries: vec![
Entry {
id: "apples".into(),
value: Translation::Plural(
Plural::new(
"apples",
vec![(PluralCategory::Other, "%d apples".to_string())].into_iter(),
)
.unwrap(),
),
comment: None,
status: EntryStatus::Translated,
custom: Default::default(),
},
Entry {
id: "bananas".into(),
value: Translation::Plural(
Plural::new(
"bananas",
vec![
(PluralCategory::One, "One banana".to_string()),
(PluralCategory::Other, "%d bananas".to_string()),
]
.into_iter(),
)
.unwrap(),
),
comment: None,
status: EntryStatus::Translated,
custom: Default::default(),
},
],
};
let added = autofix_fill_missing_from_other_resource(&mut resource);
assert!(added >= 1);
assert!(matches!(
resource.entries[0].status,
EntryStatus::NeedsReview
));
assert!(matches!(
resource.entries[1].status,
EntryStatus::Translated
));
}
}