Skip to content

Commit 060ecae

Browse files
authored
Merge pull request #4 from WendellXY/feature/plural-rules-engine
feat: plural rules engine, validation reports, CLI integration, and autofix
2 parents bf6ee32 + 649ca13 commit 060ecae

10 files changed

Lines changed: 592 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ This is a `0.4.0` release available on [crates.io](https://crates.io/crates/lang
6161
- Stats (JSON): `langcodec stats -i Localizable.xcstrings --json`
6262
- See full options: langcodec-cli/README.md#stats
6363
- Example output:
64+
6465
```json
6566
{
6667
"summary": { "languages": 1, "unique_keys": 42 },

ROADMAP.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ Legend: [ ] todo, [x] done, [~] in progress
2222
- [x] Detect placeholder mismatches across languages; strict vs non‑strict modes
2323
- [x] Auto‑fix option for common cases (`normalize_placeholders_in_place`)
2424
- [x] Tests across singular and plural entries; cross‑language normalization
25-
- [ ] Plural rules engine
26-
- [ ] CLDR‑driven required category sets per locale (few/many/etc.)
27-
- [ ] Validation pass: flag missing categories per key+locale
28-
- [ ] CLI: `view --check-plurals` and `validate` output
25+
- [~] Plural rules engine
26+
- [x] CLDR‑driven required category sets per locale (few/many/etc.)
27+
- [x] Validation pass: flag missing categories per key+locale
28+
- [~] CLI: `view --check-plurals` and `validate` output
2929
- [ ] Strict vs. permissive parsing
3030
- [ ] Global setting in lib; CLI `--strict` flag
3131
- [ ] Consistent error surfaces with actionable context

langcodec-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ categories = ["command-line-utilities", "internationalization", "localization"]
1212
documentation = "https://docs.rs/langcodec-cli"
1313

1414
[dependencies]
15-
langcodec = "0.4.1"
15+
langcodec = { path = "../langcodec", version = "0.4.1" }
1616
clap = { version = "4", features = ["derive"] }
1717
clap_complete = "4"
1818
unicode-width = "0.2.1"

langcodec-cli/src/main.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ enum Commands {
7474
/// Display full value without truncation (even in terminal)
7575
#[arg(long)]
7676
full: bool,
77+
78+
/// Validate plural completeness against CLDR category sets
79+
#[arg(long, default_value_t = false)]
80+
check_plurals: bool,
7781
},
7882

7983
/// Merge multiple localization files into one output file with automatic format detection and conversion.
@@ -187,7 +191,12 @@ fn main() {
187191
},
188192
);
189193
}
190-
Commands::View { input, lang, full } => {
194+
Commands::View {
195+
input,
196+
lang,
197+
full,
198+
check_plurals,
199+
} => {
191200
// Create validation context
192201
let mut context = ValidationContext::new().with_input_file(input.clone());
193202

@@ -223,6 +232,16 @@ fn main() {
223232
}
224233

225234
print_view(&codec, &lang, full);
235+
236+
if check_plurals {
237+
match codec.validate_plurals() {
238+
Ok(()) => println!("\n✅ Plural validation passed"),
239+
Err(e) => {
240+
eprintln!("\n❌ Plural validation failed: {}", e);
241+
std::process::exit(2);
242+
}
243+
}
244+
}
226245
}
227246
Commands::Merge {
228247
inputs,

langcodec-cli/src/stats.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use langcodec::{Codec, types::EntryStatus};
1+
use langcodec::{Codec, collect_resource_plural_issues, types::EntryStatus};
22
use serde_json::json;
33
use std::collections::HashMap;
44

@@ -46,6 +46,10 @@ pub fn print_stats(codec: &Codec, lang_filter: &Option<String>, json_output: boo
4646
for e in &res.entries {
4747
accumulate(&mut stats, &e.status);
4848
}
49+
let plural_issues = collect_resource_plural_issues(res);
50+
let missing_plural_entries = plural_issues.len();
51+
let missing_plural_categories_total: usize =
52+
plural_issues.iter().map(|r| r.missing.len()).sum();
4953
let percent = if stats.denominator == 0 {
5054
100.0
5155
} else {
@@ -56,6 +60,8 @@ pub fn print_stats(codec: &Codec, lang_filter: &Option<String>, json_output: boo
5660
"total": stats.total,
5761
"by_status": stats.by_status,
5862
"completion_percent": (percent * 100.0).round() / 100.0,
63+
"missing_plural_entries": missing_plural_entries,
64+
"missing_plural_categories_total": missing_plural_categories_total,
5965
}));
6066
}
6167
let summary = json!({
@@ -79,6 +85,10 @@ pub fn print_stats(codec: &Codec, lang_filter: &Option<String>, json_output: boo
7985
for e in &res.entries {
8086
accumulate(&mut stats, &e.status);
8187
}
88+
let plural_issues = collect_resource_plural_issues(res);
89+
let missing_plural_entries = plural_issues.len();
90+
let missing_plural_categories_total: usize =
91+
plural_issues.iter().map(|r| r.missing.len()).sum();
8292
let percent = if stats.denominator == 0 {
8393
100.0
8494
} else {
@@ -110,5 +120,9 @@ pub fn print_stats(codec: &Codec, lang_filter: &Option<String>, json_output: boo
110120
println!(" {}: {}", k, v);
111121
}
112122
println!(" Completion: {:.2}%", percent);
123+
println!(
124+
" Missing plurals: {} (missing categories: {})",
125+
missing_plural_entries, missing_plural_categories_total
126+
);
113127
}
114128
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use std::fs;
2+
use std::process::Command;
3+
use tempfile::TempDir;
4+
5+
#[test]
6+
fn test_cli_view_check_plurals_fails_on_missing() {
7+
let temp_dir = TempDir::new().unwrap();
8+
let input_file = temp_dir.path().join("strings.xml");
9+
10+
// English requires 'one' and 'other'; provide only 'other'
11+
let xml = r#"
12+
<resources>
13+
<plurals name="apples" translatable="true">
14+
<item quantity="other">%d apples</item>
15+
</plurals>
16+
</resources>
17+
"#;
18+
fs::write(&input_file, xml).unwrap();
19+
20+
let output = Command::new("cargo")
21+
.args([
22+
"run",
23+
"--quiet",
24+
"--",
25+
"view",
26+
"-i",
27+
input_file.to_str().unwrap(),
28+
"--lang",
29+
"en",
30+
"--check-plurals",
31+
])
32+
.output()
33+
.unwrap();
34+
35+
assert!(
36+
!output.status.success(),
37+
"CLI unexpectedly succeeded: {}",
38+
String::from_utf8_lossy(&output.stdout)
39+
);
40+
let stderr = String::from_utf8_lossy(&output.stderr);
41+
assert!(
42+
stderr.contains("Plural validation failed"),
43+
"stderr: {}",
44+
stderr
45+
);
46+
}
47+
48+
#[test]
49+
fn test_cli_view_check_plurals_passes_when_complete() {
50+
let temp_dir = TempDir::new().unwrap();
51+
let input_file = temp_dir.path().join("strings.xml");
52+
53+
// English: provide 'one' and 'other'
54+
let xml = r#"
55+
<resources>
56+
<plurals name="apples" translatable="true">
57+
<item quantity="one">One apple</item>
58+
<item quantity="other">%d apples</item>
59+
</plurals>
60+
</resources>
61+
"#;
62+
fs::write(&input_file, xml).unwrap();
63+
64+
let output = Command::new("cargo")
65+
.args([
66+
"run",
67+
"--quiet",
68+
"--",
69+
"view",
70+
"-i",
71+
input_file.to_str().unwrap(),
72+
"--lang",
73+
"en",
74+
"--check-plurals",
75+
])
76+
.output()
77+
.unwrap();
78+
79+
assert!(
80+
output.status.success(),
81+
"CLI failed: {}",
82+
String::from_utf8_lossy(&output.stderr)
83+
);
84+
let stdout = String::from_utf8_lossy(&output.stdout);
85+
assert!(stdout.contains("✅ Plural validation passed"));
86+
}

langcodec/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Universal localization file toolkit for Rust. Parse, write, convert, merge.
1313
langcodec = "0.4.0"
1414
```
1515

16-
Docs: https://docs.rs/langcodec
16+
Docs: <https://docs.rs/langcodec>
1717

1818
## Quick Start
1919

langcodec/src/codec.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,60 @@ impl Codec {
613613
Ok(())
614614
}
615615

616+
/// Validates plural completeness per CLDR category sets for each locale.
617+
///
618+
/// For each plural entry in each resource, checks that all required plural
619+
/// categories for the language are present. Returns a Validation error with
620+
/// aggregated details if any are missing.
621+
pub fn validate_plurals(&self) -> Result<(), Error> {
622+
use crate::plural_rules::collect_resource_plural_issues;
623+
624+
let mut reports = Vec::new();
625+
for res in &self.resources {
626+
reports.extend(collect_resource_plural_issues(res));
627+
}
628+
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));
655+
}
656+
reports
657+
}
658+
659+
/// Autofix: fill missing plural categories using 'other' and mark entries as NeedsReview.
660+
/// Returns total categories added across all resources.
661+
pub fn autofix_fill_missing_from_other(&mut self) -> usize {
662+
use crate::plural_rules::autofix_fill_missing_from_other_resource;
663+
let mut total = 0usize;
664+
for res in &mut self.resources {
665+
total += autofix_fill_missing_from_other_resource(res);
666+
}
667+
total
668+
}
669+
616670
/// Cleans up resources by removing empty resources and entries.
617671
pub fn clean_up_resources(&mut self) {
618672
self.resources
@@ -962,7 +1016,18 @@ impl Codec {
9621016
path: P,
9631017
format_type: FormatType,
9641018
) -> Result<(), Error> {
965-
let language = crate::converter::infer_language_from_path(&path, &format_type)?;
1019+
let mut language = crate::converter::infer_language_from_path(&path, &format_type)?;
1020+
// Fallback to explicitly provided language if inference failed
1021+
if language.is_none() {
1022+
match &format_type {
1023+
FormatType::Strings(lang_opt) | FormatType::AndroidStrings(lang_opt) => {
1024+
if let Some(l) = lang_opt {
1025+
language = Some(l.clone());
1026+
}
1027+
}
1028+
_ => {}
1029+
}
1030+
}
9661031

9671032
let domain = path
9681033
.as_ref()

langcodec/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ pub mod converter;
143143
pub mod error;
144144
pub mod formats;
145145
pub mod placeholder;
146+
pub mod plural_rules;
146147
pub mod traits;
147148
pub mod types;
148149

@@ -158,6 +159,10 @@ pub use crate::{
158159
error::Error,
159160
formats::FormatType,
160161
placeholder::{extract_placeholders, normalize_placeholders, signature},
162+
plural_rules::{
163+
PluralValidationReport, autofix_fill_missing_from_other_resource,
164+
collect_resource_plural_issues, required_categories_for_str, validate_resource_plurals,
165+
},
161166
types::{
162167
ConflictStrategy, Entry, EntryStatus, Metadata, Plural, PluralCategory, Resource,
163168
Translation,

0 commit comments

Comments
 (0)