fn is_separator(ch: char) -> bool {
ch.is_whitespace() || matches!(ch, '\u{001C}'..='\u{001F}')
}
pub fn normalize_ar(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut pending_space = false;
for ch in text.chars() {
match ch {
'\u{0610}'..='\u{061A}'
| '\u{064B}'..='\u{065F}'
| '\u{0640}'
| '\u{0670}'
| '\u{06D6}'..='\u{06ED}' => continue,
'\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
| '\u{FEFF}' => continue,
_ => {}
}
if is_separator(ch) {
pending_space = !out.is_empty();
continue;
}
if pending_space {
out.push(' ');
pending_space = false;
}
match ch {
'آ' | 'أ' | 'إ' | 'ٱ' => out.push('ا'),
'ى' => out.push('ي'), 'ة' => out.push('ه'), 'ؤ' => out.push('و'),
'ئ' => out.push('ي'),
'\u{0660}'..='\u{0669}' => out.push((b'0' + (ch as u32 - 0x0660) as u8) as char),
'\u{06F0}'..='\u{06F9}' => out.push((b'0' + (ch as u32 - 0x06F0) as u8) as char),
'\u{066B}' => out.push('.'),
other => out.extend(other.to_lowercase()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::normalize_ar;
#[test]
fn folds_alef_and_hamza_carriers() {
assert_eq!(normalize_ar("أ"), "ا");
assert_eq!(normalize_ar("إ"), "ا");
assert_eq!(normalize_ar("آ"), "ا");
assert_eq!(normalize_ar("ٱ"), "ا");
assert_eq!(normalize_ar("مصطفى"), "مصطفي"); assert_eq!(normalize_ar("مسؤول"), "مسوول"); }
#[test]
fn taa_marbuta_becomes_haa() {
let r = normalize_ar("طماطة");
assert!(!r.contains('ة'));
assert!(r.ends_with('ه'));
}
#[test]
fn strips_tatweel_and_diacritics() {
assert_eq!(normalize_ar("سـلام"), "سلام"); assert_eq!(normalize_ar("سَلاَم"), "سلام"); }
#[test]
fn digits_and_decimal_separator() {
assert_eq!(normalize_ar("١٢٣"), "123"); assert_eq!(normalize_ar("۱۲۳"), "123"); assert_eq!(normalize_ar("١٢٫٥"), "12.5"); }
#[test]
fn ascii_lowercased_and_whitespace_collapsed() {
assert_eq!(normalize_ar("Tomato"), "tomato");
assert_eq!(normalize_ar(" a b "), "a b");
assert_eq!(normalize_ar("a\t\tb"), "a b");
assert_eq!(normalize_ar(""), "");
assert_eq!(normalize_ar(" "), "");
}
#[test]
fn strips_invisible_format_controls() {
assert_eq!(normalize_ar("طم\u{200C}طم"), "طمطم"); assert_eq!(normalize_ar("طم\u{200B}طم"), "طمطم"); assert_eq!(normalize_ar("طم\u{200D}طم"), "طمطم"); assert_eq!(normalize_ar("\u{200F}مرحبا"), "مرحبا"); assert_eq!(normalize_ar("\u{200E}مرحبا"), "مرحبا"); assert_eq!(normalize_ar("\u{FEFF}مرحبا"), "مرحبا"); assert_eq!(normalize_ar("\u{2066}مرحبا\u{2069}"), "مرحبا"); assert_eq!(normalize_ar("\u{202A}مرحبا\u{202C}"), "مرحبا"); assert_eq!(normalize_ar("\u{202E}مرحبا\u{202C}"), "مرحبا"); }
#[test]
fn a_format_control_next_to_a_space_does_not_create_one() {
assert_eq!(normalize_ar("طماطم\u{200B} ١٢٫٥"), "طماطم 12.5");
assert_eq!(normalize_ar("طماطم \u{200B}طم"), "طماطم طم");
assert_eq!(normalize_ar("\u{200B} طم"), "طم");
assert_eq!(normalize_ar("طم \u{200B}"), "طم");
}
#[test]
fn idempotent() {
let samples = [
"أحمد",
"طماطة ١٢٫٥ جنيه",
"Hello World",
"سـ__لامٌ عليكم",
"\u{2066}طم\u{200C}طم\u{2069}",
];
for s in samples {
let once = normalize_ar(s);
assert_eq!(normalize_ar(&once), once, "not idempotent on {s:?}");
}
}
fn reference_before_the_rewrite(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\u{0610}'..='\u{061A}'
| '\u{064B}'..='\u{065F}'
| '\u{0640}'
| '\u{0670}'
| '\u{06D6}'..='\u{06ED}' => {}
'آ' | 'أ' | 'إ' | 'ٱ' => out.push('ا'),
'ى' => out.push('ي'),
'ة' => out.push('ه'),
'ؤ' => out.push('و'),
'ئ' => out.push('ي'),
'\u{0660}'..='\u{0669}' => out.push((b'0' + (ch as u32 - 0x0660) as u8) as char),
'\u{06F0}'..='\u{06F9}' => out.push((b'0' + (ch as u32 - 0x06F0) as u8) as char),
'\u{066B}' => out.push('.'),
other => out.extend(other.to_lowercase()),
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn is_deliberately_changed(c: char) -> bool {
matches!(c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
|| matches!(c, '\u{001C}'..='\u{001F}')
}
fn is_format_control(c: char) -> bool {
matches!(c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
}
#[test]
fn the_whitespace_rewrite_changed_nothing_else() {
let ranges = [
0x0009u32..=0x000D,
0x0020..=0x00FF,
0x0600..=0x06FF,
0x0750..=0x077F,
0x08A0..=0x08FF,
0x2000..=0x206F,
0xFB50..=0xFDFF,
0xFE70..=0xFEFF,
];
for range in ranges {
for cp in range {
let Some(c) = char::from_u32(cp) else {
continue;
};
if is_deliberately_changed(c) {
continue;
}
for probe in [
c.to_string(),
format!(" a {c} b "),
format!("{c}{c} طم {c}"),
] {
assert_eq!(
normalize_ar(&probe),
reference_before_the_rewrite(&probe),
"diverged on U+{cp:04X} in {probe:?}"
);
}
}
}
}
#[test]
fn every_format_control_is_stripped_and_was_not_already() {
for cp in 0x0000u32..=0x2FFF {
let Some(c) = char::from_u32(cp) else {
continue;
};
if !is_format_control(c) {
continue;
}
let probe = format!("طم{c}طم");
assert_eq!(normalize_ar(&probe), "طمطم", "U+{cp:04X} not stripped");
assert_ne!(
reference_before_the_rewrite(&probe),
"طمطم",
"U+{cp:04X} was already handled; it does not belong in the new arm"
);
}
}
#[test]
fn the_ascii_separators_collapse_like_whitespace() {
for c in ['\u{001C}', '\u{001D}', '\u{001E}', '\u{001F}'] {
assert_eq!(
normalize_ar(&c.to_string()),
"",
"U+{:04X} survived",
c as u32
);
assert_eq!(normalize_ar(&format!("طم{c}طم")), "طم طم");
assert_eq!(normalize_ar(&format!("طم {c} طم")), "طم طم");
assert_eq!(normalize_ar(&format!("{c}طم{c}")), "طم");
}
}
}