use core::str::Chars;
use unicode_general_category::{get_general_category, GeneralCategory};
use unicode_normalization::char::canonical_combining_class;
use unicode_normalization::UnicodeNormalization;
use vitaminc_normalize::NormalizeError;
use vitaminc_protected::{Controlled, Protected};
use crate::casefold_unicode16::FOLD;
pub(crate) fn folded(text: &str) -> Result<Protected<Vec<u8>>, NormalizeError> {
if text
.chars()
.any(|c| get_general_category(c) == GeneralCategory::Unassigned)
{
return Err(NormalizeError::UnassignedCodePoint);
}
let len = pipeline(text).map(char::len_utf8).sum();
let mut result = Protected::new(Vec::with_capacity(len));
for c in pipeline(text) {
let mut encoded = Protected::new([0u8; 4]);
result
.risky_inner_mut()
.extend_from_slice(c.encode_utf8(encoded.risky_inner_mut()).as_bytes());
}
Ok(result)
}
fn pipeline(text: &str) -> impl Iterator<Item = char> + '_ {
text.nfd()
.flat_map(fold)
.nfd()
.filter(|&c| !is_mark(c))
.nfc()
}
fn fold(c: char) -> core::iter::Chain<core::option::IntoIter<char>, Chars<'static>> {
match FOLD.binary_search_by_key(&(c as u32), |&(code, _)| code) {
Ok(index) => None.into_iter().chain(FOLD[index].1.chars()),
Err(_) => Some(c).into_iter().chain("".chars()),
}
}
fn is_mark(c: char) -> bool {
canonical_combining_class(c) != 0
&& matches!(
get_general_category(c),
GeneralCategory::NonspacingMark
| GeneralCategory::SpacingMark
| GeneralCategory::EnclosingMark
)
}
#[cfg(test)]
mod tests {
use super::*;
fn fold_str(s: &str) -> String {
String::from_utf8(folded(s).expect("assigned").risky_ref().clone()).expect("utf-8")
}
#[test]
fn one_code_point_folds_and_then_ends() {
let take = |c: char| fold(c).take(4).collect::<String>();
assert_eq!(take('a'), "a");
assert_eq!(take('A'), "a");
assert_eq!(take('ß'), "ss");
assert_eq!(take('ffi'), "ffi");
}
#[test]
fn the_table_is_sorted_by_code_point_with_no_repeats() {
assert!(FOLD.windows(2).all(|pair| pair[0].0 < pair[1].0));
assert!(!FOLD.is_empty());
}
#[test]
fn accents_and_case_fold_and_the_result_is_nfc() {
for (input, expected) in [
("École", "ecole"),
("Straße", "strasse"),
("Σςσ", "σσσ"),
("İ", "i"),
("ffi", "ffi"),
("\u{10400}", "\u{10428}"),
("e\u{301}", "e"),
("a\u{0903}", "a\u{0903}"),
("ÅNGSTRÖM", "angstrom"),
("", ""),
] {
assert_eq!(fold_str(input), expected, "{input:?}");
}
}
#[test]
fn vowel_signs_are_kept_and_accents_of_every_script_dropped() {
let ki = ["कि", "की", "के", "को"].map(fold_str);
for (i, a) in ki.iter().enumerate() {
for b in &ki[i + 1..] {
assert_ne!(a, b, "Devanagari vowel signs are kept");
}
}
assert_eq!(fold_str("कि"), "कि");
assert_ne!(fold_str("กิ"), fold_str("กี"), "Thai vowel signs are kept");
assert_ne!(
fold_str("কি"),
fold_str("কী"),
"Bengali vowel signs are kept"
);
assert_eq!(fold_str("क\u{93c}"), "क");
assert_eq!(fold_str("क\u{94d}"), "क");
assert_eq!(fold_str("Ά"), "α");
assert_eq!(fold_str("й"), "и");
}
#[test]
fn folding_is_idempotent_and_canonically_equivalent_inputs_agree() {
for input in ["École", "Straße", "ᾈ", "Dž", "Ⅸ", "fi", "ẞ"] {
let once = fold_str(input);
assert_eq!(fold_str(&once), once, "{input:?}");
let decomposed: String = input.nfd().collect();
assert_eq!(fold_str(&decomposed), once, "{input:?}");
}
}
#[test]
fn an_unassigned_code_point_is_refused() {
assert_eq!(
folded("a\u{378}").expect_err("refused"),
NormalizeError::UnassignedCodePoint
);
}
}