Skip to content

Commit d309aab

Browse files
committed
feat(lib): add non-fatal plural validation reports and collection APIs
- Add PluralValidationReport with missing/have sets - Add collect_resource_plural_issues and Codec::collect_plural_issues - Refactor validate_plurals to fold reports into an Error - Replace match-based rules with lazy_static BTreeMap keyed by base language - Remove region/script variants unreachable with base-language selection - Keep conservative default to {Other} - Add PluralValidationReport with missing/have sets - Add collect_resource_plural_issues and Codec::collect_plural_issues - Refactor validate_plurals to fold reports into an Error - Per-language counts of entries with missing plural categories - Total missing plural categories per language
1 parent 49d4355 commit d309aab

3 files changed

Lines changed: 115 additions & 42 deletions

File tree

langcodec/src/codec.rs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -619,20 +619,41 @@ impl Codec {
619619
/// categories for the language are present. Returns a Validation error with
620620
/// aggregated details if any are missing.
621621
pub fn validate_plurals(&self) -> Result<(), Error> {
622-
use crate::plural_rules::validate_resource_plurals;
622+
use crate::plural_rules::collect_resource_plural_issues;
623623

624-
let mut problems: Vec<String> = Vec::new();
624+
let mut reports = Vec::new();
625625
for res in &self.resources {
626-
if let Err(e) = validate_resource_plurals(res) {
627-
problems.push(e.to_string());
628-
}
626+
reports.extend(collect_resource_plural_issues(res));
629627
}
630628

631-
if problems.is_empty() {
632-
Ok(())
633-
} else {
634-
Err(Error::validation_error(problems.join("\n")))
629+
if reports.is_empty() {
630+
return Ok(());
631+
}
632+
633+
// Fold into an Error message for the validating API
634+
let mut lines = Vec::new();
635+
for r in reports {
636+
let miss: Vec<String> = r.missing.iter().map(|k| format!("{:?}", k)).collect();
637+
let have: Vec<String> = r.have.iter().map(|k| format!("{:?}", k)).collect();
638+
lines.push(format!(
639+
"lang='{}' key='{}': missing plural categories: [{}] (have: [{}])",
640+
r.language,
641+
r.key,
642+
miss.join(", "),
643+
have.join(", ")
644+
));
645+
}
646+
Err(Error::validation_error(lines.join("\n")))
647+
}
648+
649+
/// Collects non-fatal plural validation reports across all resources.
650+
pub fn collect_plural_issues(&self) -> Vec<crate::plural_rules::PluralValidationReport> {
651+
use crate::plural_rules::collect_resource_plural_issues;
652+
let mut reports = Vec::new();
653+
for res in &self.resources {
654+
reports.extend(collect_resource_plural_issues(res));
635655
}
656+
reports
636657
}
637658

638659
/// Cleans up resources by removing empty resources and entries.

langcodec/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ pub use crate::{
159159
error::Error,
160160
formats::FormatType,
161161
placeholder::{extract_placeholders, normalize_placeholders, signature},
162-
plural_rules::{required_categories_for_str, validate_resource_plurals},
162+
plural_rules::{
163+
collect_resource_plural_issues, required_categories_for_str, validate_resource_plurals,
164+
PluralValidationReport,
165+
},
163166
types::{
164167
ConflictStrategy, Entry, EntryStatus, Metadata, Plural, PluralCategory, Resource,
165168
Translation,

langcodec/src/plural_rules.rs

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
types::{Plural, PluralCategory, Resource, Translation},
88
};
99

10+
use serde::Serialize;
1011
use lazy_static::lazy_static;
1112

1213
lazy_static! {
@@ -74,6 +75,15 @@ lazy_static! {
7475
};
7576
}
7677

78+
/// Non-fatal report describing missing plural categories for a key in a locale.
79+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80+
pub struct PluralValidationReport {
81+
pub language: String,
82+
pub key: String,
83+
pub missing: BTreeSet<PluralCategory>,
84+
pub have: BTreeSet<PluralCategory>,
85+
}
86+
7787
/// Returns the required CLDR plural categories for a given language identifier.
7888
///
7989
/// This is a curated subset of CLDR rules covering common locales. For unknown
@@ -109,49 +119,57 @@ pub fn missing_categories_for_plural(
109119
&required - &have
110120
}
111121

112-
/// Validate a single resource for missing plural categories.
113-
pub fn validate_resource_plurals(resource: &Resource) -> Result<(), Error> {
114-
let lang_id = match resource.parse_language_identifier() {
115-
Some(id) => id,
116-
None => {
117-
return Err(Error::validation_error(format!(
118-
"Invalid or missing language for resource: {}",
119-
resource.metadata.language
120-
)));
121-
}
122+
/// Collect non-fatal plural issues for a single resource.
123+
pub fn collect_resource_plural_issues(resource: &Resource) -> Vec<PluralValidationReport> {
124+
let Some(lang_id) = resource.parse_language_identifier() else {
125+
return vec![PluralValidationReport {
126+
language: resource.metadata.language.clone(),
127+
key: String::from("<resource>"),
128+
missing: [PluralCategory::Other].into_iter().collect(),
129+
have: BTreeSet::new(),
130+
}];
122131
};
123132

124-
let mut problems: Vec<String> = Vec::new();
125-
133+
let mut reports = Vec::new();
126134
for entry in &resource.entries {
127135
if let Translation::Plural(plural) = &entry.value {
136+
let have: BTreeSet<PluralCategory> = plural.forms.keys().cloned().collect();
128137
let missing = missing_categories_for_plural(&lang_id, plural);
129138
if !missing.is_empty() {
130-
let have: Vec<String> = plural
131-
.forms
132-
.keys()
133-
.map(|k| format!("{:?}", k))
134-
.collect();
135-
let miss: Vec<String> = missing.into_iter().map(|k| format!("{:?}", k)).collect();
136-
problems.push(format!(
137-
"lang='{}' key='{}': missing plural categories: [{}] (have: [{}])",
138-
resource.metadata.language,
139-
entry.id,
140-
miss.join(", "),
141-
have.join(", ")
142-
));
139+
reports.push(PluralValidationReport {
140+
language: resource.metadata.language.clone(),
141+
key: entry.id.clone(),
142+
missing,
143+
have,
144+
});
143145
}
144146
}
145147
}
148+
reports
149+
}
146150

147-
if problems.is_empty() {
148-
Ok(())
149-
} else {
150-
Err(Error::validation_error(format!(
151-
"Plural validation failed:\n{}",
152-
problems.join("\n")
153-
)))
151+
/// Validate a single resource for missing plural categories.
152+
pub fn validate_resource_plurals(resource: &Resource) -> Result<(), Error> {
153+
let reports = collect_resource_plural_issues(resource);
154+
if reports.is_empty() {
155+
return Ok(());
156+
}
157+
let mut lines = Vec::new();
158+
for r in reports {
159+
let miss: Vec<String> = r.missing.iter().map(|k| format!("{:?}", k)).collect();
160+
let have: Vec<String> = r.have.iter().map(|k| format!("{:?}", k)).collect();
161+
lines.push(format!(
162+
"lang='{}' key='{}': missing plural categories: [{}] (have: [{}])",
163+
r.language,
164+
r.key,
165+
miss.join(", "),
166+
have.join(", ")
167+
));
154168
}
169+
Err(Error::validation_error(format!(
170+
"Plural validation failed:\n{}",
171+
lines.join("\n")
172+
)))
155173
}
156174

157175
#[cfg(test)]
@@ -207,4 +225,35 @@ mod tests {
207225
let err = validate_resource_plurals(&resource).unwrap_err();
208226
assert!(format!("{}", err).contains("missing plural categories"));
209227
}
228+
229+
#[test]
230+
fn test_collect_resource_plural_issues() {
231+
// English requires one/other; missing 'one' should yield a report
232+
let resource = Resource {
233+
metadata: Metadata {
234+
language: "en".into(),
235+
domain: String::new(),
236+
custom: Default::default(),
237+
},
238+
entries: vec![Entry {
239+
id: "apples".into(),
240+
value: Translation::Plural(Plural::new(
241+
"apples",
242+
vec![(PluralCategory::Other, "%d apples".to_string())].into_iter(),
243+
)
244+
.unwrap()),
245+
comment: None,
246+
status: EntryStatus::Translated,
247+
custom: Default::default(),
248+
}],
249+
};
250+
251+
let reports = collect_resource_plural_issues(&resource);
252+
assert_eq!(reports.len(), 1);
253+
let r = &reports[0];
254+
assert_eq!(r.language, "en");
255+
assert_eq!(r.key, "apples");
256+
assert!(r.missing.contains(&PluralCategory::One));
257+
assert!(r.have.contains(&PluralCategory::Other));
258+
}
210259
}

0 commit comments

Comments
 (0)