use caseless::Caseless;
use unicode_normalization::UnicodeNormalization;
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
const COMBINING_DOT_ABOVE: char = '\u{0307}';
const TATWEEL: char = '\u{0640}';
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum FoldLocale {
#[default]
Root,
Turkic,
}
impl FoldLocale {
pub fn from_tag(tag: &str) -> Self {
let primary = tag.split(['-', '_']).next().unwrap_or("");
if primary.eq_ignore_ascii_case("tr") || primary.eq_ignore_ascii_case("az") {
FoldLocale::Turkic
} else {
FoldLocale::Root
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct FoldSpec {
pub case_sensitive: bool,
pub diacritic_sensitive: bool,
pub locale: FoldLocale,
}
const LETTER_FOLD: &[(char, &str)] = &[
('\u{00C6}', "AE"), ('\u{00D0}', "D"), ('\u{00D8}', "O"), ('\u{00DE}', "TH"), ('\u{00E6}', "ae"), ('\u{00F0}', "d"), ('\u{00F8}', "o"), ('\u{00FE}', "th"), ('\u{0110}', "D"), ('\u{0111}', "d"), ('\u{0126}', "H"), ('\u{0127}', "h"), ('\u{0131}', "i"), ('\u{0141}', "L"), ('\u{0142}', "l"), ('\u{0152}', "OE"), ('\u{0153}', "oe"), ('\u{0166}', "T"), ('\u{0167}', "t"), ('\u{0629}', "\u{0647}"), ('\u{0649}', "\u{064A}"), ('\u{0671}', "\u{0627}"), ];
fn letter_fold(c: char, locale: FoldLocale) -> Option<&'static str> {
if locale == FoldLocale::Turkic && c == '\u{0131}' {
return None;
}
LETTER_FOLD
.binary_search_by_key(&c, |&(k, _)| k)
.ok()
.map(|i| LETTER_FOLD[i].1)
}
fn emit_folded(c: char, spec: &FoldSpec, emit: &mut impl FnMut(char)) {
if spec.diacritic_sensitive {
emit(c);
return;
}
if c == TATWEEL || c.general_category() == GeneralCategory::NonspacingMark {
return;
}
match letter_fold(c, spec.locale) {
Some(replacement) => replacement.chars().for_each(&mut *emit),
None => emit(c),
}
}
pub(crate) fn fold_char(
c: char,
next: Option<char>,
spec: &FoldSpec,
emit: &mut impl FnMut(char),
) -> usize {
if c.is_ascii() && !(spec.locale == FoldLocale::Turkic && c == 'I') {
emit(if spec.case_sensitive {
c
} else {
c.to_ascii_lowercase()
});
return 1;
}
fold_char_general(c, next, spec, emit)
}
fn fold_char_general(
c: char,
next: Option<char>,
spec: &FoldSpec,
emit: &mut impl FnMut(char),
) -> usize {
if !spec.case_sensitive && spec.locale == FoldLocale::Turkic {
match c {
'\u{0130}' => {
emit_folded('i', spec, emit);
return 1;
}
'I' if next == Some(COMBINING_DOT_ABOVE) => {
emit_folded('i', spec, emit);
return 2;
}
'I' => {
emit_folded('\u{0131}', spec, emit);
return 1;
}
_ => {}
}
}
if spec.diacritic_sensitive {
if spec.case_sensitive {
emit(c);
} else {
for folded in std::iter::once(c).default_case_fold() {
emit_folded(folded, spec, emit);
}
}
return 1;
}
for decomposed in c.nfd() {
if spec.case_sensitive {
emit_folded(decomposed, spec, emit);
} else {
for folded in std::iter::once(decomposed).default_case_fold() {
for renormalised in folded.nfd() {
emit_folded(renormalised, spec, emit);
}
}
}
}
1
}
pub(crate) fn to_upper(c: char, locale: FoldLocale, out: &mut String) {
if locale == FoldLocale::Turkic {
match c {
'i' => {
out.push('\u{0130}');
return;
}
'\u{0131}' => {
out.push('I');
return;
}
_ => {}
}
}
out.extend(c.to_uppercase());
}
#[cfg(test)]
mod tests {
use super::*;
fn fold(text: &str, spec: &FoldSpec) -> String {
let mut out = String::new();
let mut it = text.chars().peekable();
while let Some(c) = it.next() {
let consumed = fold_char(c, it.peek().copied(), spec, &mut |g| out.push(g));
if consumed == 2 {
it.next();
}
}
out
}
fn loose() -> FoldSpec {
FoldSpec::default()
}
fn turkic() -> FoldSpec {
FoldSpec {
locale: FoldLocale::Turkic,
..FoldSpec::default()
}
}
#[test]
fn the_letter_table_is_sorted_and_has_no_duplicates() {
let keys: Vec<char> = LETTER_FOLD.iter().map(|&(k, _)| k).collect();
let mut sorted = keys.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(keys, sorted, "LETTER_FOLD must be sorted and unique");
}
#[test]
fn a_plain_query_folds_onto_accented_prose() {
for (accented, plain) in [
("Aurélien", "aurelien"),
("café", "cafe"),
("Brønnøysund", "bronnoysund"),
("œuvre", "oeuvre"),
("Ægir", "aegir"),
("Þingvellir", "thingvellir"),
("Łódź", "lodz"),
("ệ", "e"),
] {
assert_eq!(
fold(accented, &loose()),
fold(plain, &loose()),
"{accented:?} must be found by {plain:?}"
);
}
}
#[test]
fn the_letter_table_is_reached_by_the_uppercase_form_too() {
assert_eq!(fold("Ørsted", &loose()), fold("orsted", &loose()));
assert_eq!(fold("Ørsted", &loose()), "orsted");
}
#[test]
fn the_letter_table_keeps_case_when_case_matters() {
let spec = FoldSpec {
case_sensitive: true,
..FoldSpec::default()
};
assert_eq!(fold("Ørsted", &spec), "Orsted");
assert_eq!(fold("ÆØÅ", &spec), "AEOA");
assert_ne!(fold("Ørsted", &spec), fold("ørsted", &spec));
}
#[test]
fn full_case_folding_expands_the_sharp_s() {
let spec = FoldSpec {
diacritic_sensitive: true,
..FoldSpec::default()
};
assert_eq!(fold("Straße", &spec), "strasse");
assert_eq!(fold("Straße", &spec), fold("STRASSE", &spec));
assert_eq!(fold("final", &spec), "final");
assert_eq!(fold("ΟΔΟΣ", &spec), fold("οδος", &spec));
}
#[test]
fn arabic_orthographic_variants_fold_together() {
assert_eq!(fold("أَحْمَد", &loose()), fold("احمد", &loose()));
assert_eq!(fold("ٱلكتاب", &loose()), fold("الكتاب", &loose()));
assert_eq!(fold("مصطفى", &loose()), fold("مصطفي", &loose()));
assert_eq!(fold("مدينة", &loose()), fold("مدينه", &loose()));
assert_eq!(fold("كــتــاب", &loose()), fold("كتاب", &loose()));
}
#[test]
fn hebrew_niqqud_fold_but_the_geresh_survives() {
assert_eq!(fold("שָׁלוֹם", &loose()), fold("שלום", &loose()));
assert!(
fold("צ\u{05F3}", &loose()).contains('\u{05F3}'),
"the geresh is punctuation, not a diacritic"
);
}
#[test]
fn turkish_keeps_the_dotted_and_dotless_i_apart() {
assert_eq!(fold("İstanbul", &turkic()), fold("istanbul", &turkic()));
assert_eq!(fold("KISA", &turkic()), fold("kısa", &turkic()));
assert_ne!(
fold("kısa", &turkic()),
fold("kisa", &turkic()),
"in Turkish the dotless i is a different letter"
);
assert_eq!(fold("kısa", &loose()), fold("kisa", &loose()));
}
#[test]
fn a_decomposed_turkish_capital_i_folds_the_same_as_the_composed_one() {
let decomposed = "I\u{0307}stanbul";
assert_eq!(
decomposed.chars().count(),
9,
"the fixture must really be NFD"
);
assert_eq!(fold(decomposed, &turkic()), fold("İstanbul", &turkic()));
assert_eq!(fold(decomposed, &turkic()), "istanbul");
}
#[test]
fn folding_and_mark_stripping_commute_across_the_whole_f_status_set() {
let mut checked = 0;
for cp in 0u32..=0x10FFFF {
let Some(c) = char::from_u32(cp) else {
continue;
};
let expanded: String = std::iter::once(c).default_case_fold().collect();
if expanded.chars().count() < 2 {
continue;
}
checked += 1;
let direct = fold(&c.to_string(), &loose());
let piecewise: String = c
.nfd()
.map(|d| fold(&d.to_string(), &loose()))
.collect::<Vec<_>>()
.concat();
assert_eq!(
direct, piecewise,
"U+{cp:04X} {c:?} folds differently depending on when it is decomposed"
);
}
assert_eq!(
checked, 104,
"the F-status set changed size — re-check the fold against the new Unicode data"
);
}
#[test]
fn the_two_toggles_are_independent() {
let dia = FoldSpec {
diacritic_sensitive: true,
..FoldSpec::default()
};
assert_eq!(fold("CAFÉ", &dia), fold("café", &dia));
assert_ne!(fold("cafe", &dia), fold("café", &dia));
let case = FoldSpec {
case_sensitive: true,
..FoldSpec::default()
};
assert_eq!(fold("cafe", &case), fold("café", &case));
assert_ne!(fold("CAFE", &case), fold("cafe", &case));
let strict = FoldSpec {
case_sensitive: true,
diacritic_sensitive: true,
..FoldSpec::default()
};
assert_eq!(fold("Café ΟΔΟΣ İ ß", &strict), "Café ΟΔΟΣ İ ß");
}
#[test]
fn the_strictest_fold_changes_nothing_at_all() {
let strict = FoldSpec {
case_sensitive: true,
diacritic_sensitive: true,
..FoldSpec::default()
};
let mut checked = 0;
for cp in 0u32..=0x10FFFF {
let Some(c) = char::from_u32(cp) else {
continue;
};
if c.nfd().count() == 1 && std::iter::once(c).default_case_fold().count() == 1 {
continue; }
checked += 1;
let s = c.to_string();
assert_eq!(
fold(&s, &strict),
s,
"U+{cp:04X} {c:?} was altered by a fold that must be the identity"
);
}
assert_eq!(
checked, 12253,
"the count of chars that decompose or multi-fold changed — the Unicode data \
moved under us, so re-check the fold against it"
);
}
#[test]
fn turkish_uppercase_keeps_the_dot() {
let mut out = String::new();
to_upper('i', FoldLocale::Turkic, &mut out);
assert_eq!(out, "İ");
let mut out = String::new();
to_upper('\u{0131}', FoldLocale::Turkic, &mut out);
assert_eq!(out, "I");
let mut out = String::new();
to_upper('i', FoldLocale::Root, &mut out);
assert_eq!(out, "I");
}
#[test]
fn the_fast_path_agrees_with_the_general_path_on_every_char() {
let specs = [
FoldSpec::default(),
FoldSpec {
case_sensitive: true,
..FoldSpec::default()
},
FoldSpec {
diacritic_sensitive: true,
..FoldSpec::default()
},
FoldSpec {
case_sensitive: true,
diacritic_sensitive: true,
..FoldSpec::default()
},
FoldSpec {
locale: FoldLocale::Turkic,
..FoldSpec::default()
},
FoldSpec {
locale: FoldLocale::Turkic,
case_sensitive: true,
..FoldSpec::default()
},
];
for next in [None, Some(COMBINING_DOT_ABOVE), Some('x')] {
for spec in &specs {
for cp in 0u32..=0x10FFFF {
let Some(c) = char::from_u32(cp) else {
continue;
};
let (mut fast, mut general) = (String::new(), String::new());
let fast_consumed = fold_char(c, next, spec, &mut |g| fast.push(g));
let general_consumed =
fold_char_general(c, next, spec, &mut |g| general.push(g));
assert_eq!(
(fast, fast_consumed),
(general, general_consumed),
"U+{cp:04X} {c:?} folds differently on the fast path \
(spec={spec:?}, next={next:?})"
);
}
}
}
}
#[test]
fn a_malformed_language_tag_falls_back_to_the_root_locale() {
assert_eq!(FoldLocale::from_tag("tr"), FoldLocale::Turkic);
assert_eq!(FoldLocale::from_tag("tr-TR"), FoldLocale::Turkic);
assert_eq!(FoldLocale::from_tag("TR_tr"), FoldLocale::Turkic);
assert_eq!(FoldLocale::from_tag("az-Latn-AZ"), FoldLocale::Turkic);
assert_eq!(FoldLocale::from_tag("fr-FR"), FoldLocale::Root);
assert_eq!(FoldLocale::from_tag(""), FoldLocale::Root);
assert_eq!(FoldLocale::from_tag("-----"), FoldLocale::Root);
assert_eq!(FoldLocale::from_tag("nonsense!!"), FoldLocale::Root);
}
}