use crate::kcc::{is_khmer_base, is_khmer_combining, COENG, ROBAT};
fn is_reorderable_mark(c: char) -> bool {
is_khmer_combining(c) && c != COENG && c != ROBAT && !matches!(c as u32, 0x200C..=0x200D)
}
pub fn normalize(text: &str) -> String {
let mut chars: Vec<char> = text.chars().collect();
loop {
let mut changed = false;
let mut i = 0;
while i + 2 < chars.len() {
if is_reorderable_mark(chars[i]) && chars[i + 1] == COENG {
chars[i..i + 3].rotate_left(1);
changed = true;
}
else if chars[i] == COENG
&& is_reorderable_mark(chars[i + 1])
&& is_khmer_base(chars[i + 2])
{
chars[i + 1..i + 3].rotate_left(1);
changed = true;
}
i += 1;
}
if !changed {
break;
}
}
chars.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reorders_a_vowel_typed_before_a_subscript() {
assert_eq!(normalize("សិទិ្ធ"), "សិទ្ធិ");
}
#[test]
fn reorders_a_shifter_typed_before_a_subscript() {
let malformed = "ប\u{17CA}\u{17D2}\u{179B}\u{17B7}";
let canonical = "ប\u{17D2}\u{179B}\u{17CA}\u{17B7}";
assert_eq!(normalize(malformed), canonical);
}
#[test]
fn leaves_a_legitimate_robat_before_a_subscript_alone() {
let already_canonical = "ក\u{17CC}\u{17D2}\u{1780}";
assert_eq!(normalize(already_canonical), already_canonical);
}
#[test]
fn is_a_no_op_on_already_canonical_text() {
assert_eq!(normalize("កម្ពុជា"), "កម្ពុជា");
assert_eq!(normalize("សិទ្ធិ"), "សិទ្ធិ");
}
#[test]
fn is_idempotent() {
let malformed = "សិទិ្ធ";
let once = normalize(malformed);
let twice = normalize(&once);
assert_eq!(once, twice);
}
#[test]
fn preserves_byte_length() {
let malformed = "សិទិ្ធ";
assert_eq!(normalize(malformed).len(), malformed.len());
}
#[test]
fn cascades_through_a_mark_before_a_doubly_stacked_subscript() {
let malformed = "ស\u{17B7}\u{17D2}\u{178F}\u{17D2}\u{179A}";
let canonical = "ស\u{17D2}\u{178F}\u{17D2}\u{179A}\u{17B7}";
assert_eq!(normalize(malformed), canonical);
}
#[test]
fn repairs_nfc_stranded_sign_between_coeng_and_consonant() {
let nfc_damaged = "ក\u{17D2}\u{17DD}ក";
let canonical = "ក\u{17D2}ក\u{17DD}";
assert_eq!(normalize(nfc_damaged), canonical);
}
#[test]
fn both_corruption_orders_converge_on_the_same_canonical_form() {
let typed_error = "ក\u{17DD}\u{17D2}ក"; let nfc_damaged = "ក\u{17D2}\u{17DD}ក"; assert_eq!(normalize(typed_error), normalize(nfc_damaged));
assert_eq!(normalize(typed_error), "ក\u{17D2}ក\u{17DD}");
}
#[test]
fn leaves_zero_width_joiners_alone_in_both_positions() {
let zwnj_before_coeng = "ក\u{200C}\u{17D2}ក";
assert_eq!(normalize(zwnj_before_coeng), zwnj_before_coeng);
let zwj_after_coeng = "ក\u{17D2}\u{200D}ក";
assert_eq!(normalize(zwj_after_coeng), zwj_after_coeng);
}
#[test]
fn leaves_a_trailing_stranded_mark_alone() {
let dangling = "ក\u{17D2}\u{17DD}";
assert_eq!(normalize(dangling), dangling);
}
#[test]
fn nfc_repair_is_idempotent_and_length_preserving() {
let nfc_damaged = "ក\u{17D2}\u{17DD}ក";
let once = normalize(nfc_damaged);
assert_eq!(normalize(&once), once);
assert_eq!(once.len(), nfc_damaged.len());
}
}