use std::borrow::Cow;
use crate::{case_fold, confusables, emoji, invisibles, transliterate, whitespace, zalgo};
const COMPARISON_STRIP: invisibles::StripPolicy = invisibles::StripPolicy {
strip_pua: true,
keep_presentation_vs: false,
};
const RENDERING_STRIP: invisibles::StripPolicy = invisibles::StripPolicy {
strip_pua: false,
keep_presentation_vs: true,
};
const CONFUSABLE_FIXED_POINT_ITERS: usize = 8;
struct PresetCtx<'a> {
lang: Option<&'a str>,
strict_iso9: bool,
emoji_cldr: bool,
}
#[derive(Clone, Copy)]
enum Step {
Nfkc,
Nfc,
NfcIfNonAscii,
StripBidi,
StripInvisible(invisibles::StripPolicy),
StripControl,
StripZeroWidth,
CollapseWs,
Zalgo(usize),
FoldCase,
StripAccents,
Transliterate {
mode: crate::ErrorMode,
only_if_lang: bool,
},
TranslitPreservingLatin,
Confusables(&'static str),
ConfusablesNfcFixedPoint(&'static str),
FixedPoint(&'static [Step]),
Demojize {
only_if_cldr: bool,
},
}
fn apply_into(
step: Step,
input: &str,
ctx: &PresetCtx,
out: &mut String,
) -> Result<bool, crate::ErrorRepr> {
match step {
Step::Nfkc => {
crate::normalize::normalize_into(input, "NFKC", out)?;
Ok(true)
}
Step::Nfc => {
crate::normalize::normalize_into(input, "NFC", out)?;
Ok(true)
}
Step::NfcIfNonAscii => {
if input.is_ascii() {
Ok(false)
} else {
crate::normalize::normalize_into(input, "NFC", out)?;
Ok(true)
}
}
Step::StripBidi => {
strip_bidi_into(input, out);
Ok(true)
}
Step::StripInvisible(policy) => {
invisibles::strip_invisible_classes_into(input, policy, out);
Ok(true)
}
Step::StripControl => {
whitespace::strip_control_chars_into(input, out);
Ok(true)
}
Step::StripZeroWidth => {
whitespace::strip_zero_width_chars_into(input, out);
Ok(true)
}
Step::CollapseWs => {
whitespace::collapse_whitespace_into(input, out);
Ok(true)
}
Step::Zalgo(cap) => {
zalgo::strip_zalgo_into(input, cap, out);
Ok(true)
}
Step::FoldCase => {
case_fold::fold_case_into(input, out);
Ok(true)
}
Step::StripAccents => {
transliterate::strip_accents_into(input, out);
Ok(true)
}
Step::Transliterate { mode, only_if_lang } => {
if only_if_lang && ctx.lang.is_none() {
return Ok(false);
}
match transliterate::transliterate_impl(
input,
ctx.lang,
mode,
"",
ctx.strict_iso9,
false,
false,
) {
Cow::Borrowed(_) => Ok(false),
Cow::Owned(s) => {
*out = s;
Ok(true)
}
}
}
Step::TranslitPreservingLatin => {
transliterate_preserving_latin_into(input, ctx.lang, out);
Ok(true)
}
Step::Confusables(target) => {
confusables::normalize_confusables_into(input, target, out)?;
Ok(true)
}
Step::ConfusablesNfcFixedPoint(target) => {
let mut cur = input.to_owned();
let mut conf = String::new();
let mut nxt = String::new();
let mut cur_is_nfc = false;
for _ in 0..CONFUSABLE_FIXED_POINT_ITERS {
confusables::normalize_confusables_into(&cur, target, &mut conf)?;
if conf == cur && cur_is_nfc {
break;
}
crate::normalize::normalize_into(&conf, "NFC", &mut nxt)?;
if nxt == cur {
break;
}
std::mem::swap(&mut cur, &mut nxt);
cur_is_nfc = true;
}
if cur == input {
Ok(false)
} else {
*out = cur;
Ok(true)
}
}
Step::FixedPoint(inner) => {
let mut cur = input.to_owned();
for _ in 0..CONFUSABLE_FIXED_POINT_ITERS {
let next = apply_steps(inner, &cur, ctx)?;
if next == cur {
break;
}
cur = next;
}
if cur == input {
Ok(false)
} else {
*out = cur;
Ok(true)
}
}
Step::Demojize { only_if_cldr } => {
if only_if_cldr && !ctx.emoji_cldr {
return Ok(false);
}
emoji::demojize_rust_into(input, false, out);
Ok(true)
}
}
}
#[derive(Clone, Copy)]
struct Actionable {
controls: bool, collapse_ws: bool, fold_case: bool, confusables: bool, nfkc: bool, marks: bool, strip_accents: bool, zalgo_cap: Option<usize>, bidi: bool, zero_width: bool, invisible: bool, transliterate: bool, demojize: bool, }
impl Actionable {
fn for_steps(steps: &[Step]) -> Self {
let mut m = Self {
controls: false,
collapse_ws: false,
fold_case: false,
confusables: false,
nfkc: false,
marks: false,
strip_accents: false,
zalgo_cap: None,
bidi: false,
zero_width: false,
invisible: false,
transliterate: false,
demojize: false,
};
for &step in steps {
match step {
Step::StripControl => m.controls = true,
Step::CollapseWs => m.collapse_ws = true,
Step::FoldCase => m.fold_case = true,
Step::Confusables(target) | Step::ConfusablesNfcFixedPoint(target) => {
assert!(
target == "latin",
"fast-path guard supports only Latin confusable targets; \
{target:?} rewrites different sources — make the guard \
target-aware first"
);
m.confusables = true;
m.marks = true;
}
Step::FixedPoint(inner) => {
debug_assert!(
!inner.iter().any(|s| matches!(s, Step::FixedPoint(_))),
"FixedPoint inner list must not contain a nested FixedPoint"
);
m.union(Self::for_steps(inner));
}
Step::Nfkc | Step::Nfc | Step::NfcIfNonAscii => {
m.nfkc = true;
m.marks = true; }
Step::Zalgo(cap) => {
m.marks = true; m.zalgo_cap = Some(cap);
}
Step::StripAccents => {
m.marks = true;
m.strip_accents = true;
}
Step::StripBidi => m.bidi = true,
Step::StripZeroWidth => m.zero_width = true,
Step::StripInvisible(_) => m.invisible = true,
Step::Transliterate { .. } | Step::TranslitPreservingLatin => {
m.transliterate = true;
}
Step::Demojize { .. } => m.demojize = true,
}
}
m
}
fn union(&mut self, o: Self) {
self.controls |= o.controls;
self.collapse_ws |= o.collapse_ws;
self.fold_case |= o.fold_case;
self.confusables |= o.confusables;
self.nfkc |= o.nfkc;
self.marks |= o.marks;
self.strip_accents |= o.strip_accents;
self.zalgo_cap = match (self.zalgo_cap, o.zalgo_cap) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
self.bidi |= o.bidi;
self.zero_width |= o.zero_width;
self.invisible |= o.invisible;
self.transliterate |= o.transliterate;
self.demojize |= o.demojize;
}
}
const fn is_ascii_fold_ws(b: u8) -> bool {
matches!(b, 0x09..=0x0D | 0x1C..=0x1F | 0x20)
}
const fn is_removed_control(b: u8) -> bool {
(b < 0x20 && !is_ascii_fold_ws(b)) || b == 0x7F
}
fn nfkc_changes(ch: char) -> bool {
use unicode_normalization::UnicodeNormalization;
let mut it = std::iter::once(ch).nfkc();
!(it.next() == Some(ch) && it.next().is_none())
}
fn decomposes_to_mark(ch: char) -> bool {
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::UnicodeNormalization;
std::iter::once(ch).nfd().any(is_combining_mark)
}
fn nfd_mark_run_exceeds(ch: char, cap: usize) -> bool {
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::UnicodeNormalization;
let mut marks = 0usize;
for c in std::iter::once(ch).nfd() {
if is_combining_mark(c) {
marks += 1;
if marks > cap {
return true;
}
}
}
false
}
fn is_demojizable(ch: char) -> bool {
crate::tables::lookup_emoji_single(ch).is_some()
|| crate::tables::is_emoji_multi_starter(ch)
|| emoji::is_emoji_codepoint(ch)
|| emoji::is_emoji_modifier(ch)
}
fn acts_on_nonascii(
ch: char,
m: Actionable,
conf_map: Option<&'static phf::Map<char, &'static str>>,
) -> bool {
if m.transliterate {
return true;
}
(m.marks && unicode_normalization::char::is_combining_mark(ch))
|| (m.controls && ch.is_control() && !whitespace::is_fold_whitespace(ch))
|| (m.collapse_ws
&& (whitespace::is_fold_whitespace(ch) || whitespace::is_blank_render(ch)))
|| (m.bidi && is_bidi_or_format(ch))
|| (m.zero_width && whitespace::is_zero_width(ch))
|| (m.invisible
&& (invisibles::is_tag(ch)
|| invisibles::is_variation_selector(ch)
|| invisibles::is_noncharacter(ch)
|| invisibles::is_pua(ch)
|| ch == '\u{034F}')) || (m.fold_case && crate::tables::case_folding_data::lookup(ch).is_some())
|| (m.confusables && conf_map.is_some_and(|map| map.contains_key(&ch)))
|| (m.demojize && is_demojizable(ch))
|| (m.nfkc && (is_conjoining_jamo(ch) || nfkc_changes(ch)))
|| (m.strip_accents && decomposes_to_mark(ch))
|| m.zalgo_cap.is_some_and(|cap| nfd_mark_run_exceeds(ch, cap))
}
const fn is_conjoining_jamo(ch: char) -> bool {
matches!(ch as u32, 0x1100..=0x11FF | 0xA960..=0xA97F | 0xD7B0..=0xD7FF)
}
enum Guard {
Inert,
WhitespaceOnly,
Actionable,
}
fn classify(
text: &str,
mask: Actionable,
conf_map: Option<&'static phf::Map<char, &'static str>>,
) -> Guard {
let bytes = text.as_bytes();
let n = bytes.len();
let mut prev_space = false;
let mut saw_ws = false;
let mut i = 0;
while i < n {
let b = bytes[i];
if b < 0x80 {
if mask.controls && is_removed_control(b) {
return Guard::Actionable;
}
if mask.fold_case && b.is_ascii_uppercase() {
return Guard::Actionable;
}
if mask.confusables && crate::tables::is_ascii_confusable_latin(b) {
return Guard::Actionable;
}
if mask.collapse_ws && is_ascii_fold_ws(b) && b != b' ' {
saw_ws = true; prev_space = false;
} else if mask.collapse_ws && b == b' ' {
if i == 0 || i + 1 == n || prev_space {
saw_ws = true; }
prev_space = true;
} else {
prev_space = false;
}
i += 1;
} else {
let ch = text[i..].chars().next().unwrap_or('\u{FFFD}');
if acts_on_nonascii(ch, mask, conf_map) {
return Guard::Actionable;
}
prev_space = false;
i += ch.len_utf8();
}
}
if saw_ws {
Guard::WhitespaceOnly
} else {
Guard::Inert
}
}
#[cfg(test)]
thread_local! {
static FASTPATH_DISABLED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn run<'a>(
steps: &[Step],
text: &'a str,
ctx: &PresetCtx,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
#[cfg(test)]
let guard_on = !FASTPATH_DISABLED.with(std::cell::Cell::get);
#[cfg(not(test))]
let guard_on = true;
if guard_on {
let mask = Actionable::for_steps(steps);
let conf_map = if mask.confusables {
crate::tables::resolve_confusable_map("latin")
} else {
None
};
match classify(text, mask, conf_map) {
Guard::Inert => return Ok(Cow::Borrowed(text)),
Guard::WhitespaceOnly => {
let mut out = String::new();
whitespace::collapse_whitespace_into(text, &mut out);
return Ok(Cow::Owned(out));
}
Guard::Actionable => {}
}
}
Ok(Cow::Owned(apply_steps(steps, text, ctx)?))
}
fn apply_steps(steps: &[Step], input: &str, ctx: &PresetCtx) -> Result<String, crate::ErrorRepr> {
let mut cur = input.to_owned();
let mut scratch = String::new();
for &step in steps {
if apply_into(step, &cur, ctx, &mut scratch)? {
std::mem::swap(&mut cur, &mut scratch);
}
}
Ok(cur)
}
#[cfg(test)]
fn without_fastpath<R>(f: impl FnOnce() -> R) -> R {
FASTPATH_DISABLED.with(|d| d.set(true));
let r = f();
FASTPATH_DISABLED.with(|d| d.set(false));
r
}
pub(crate) fn strip_bidi(text: &str) -> String {
let mut out = String::new();
strip_bidi_into(text, &mut out);
out
}
pub(crate) fn strip_bidi_into(text: &str, out: &mut String) {
out.clear();
if text.is_ascii() {
out.push_str(text);
return;
}
out.reserve(text.len()); out.extend(text.chars().filter(|&ch| !is_bidi_or_format(ch)));
}
#[inline]
fn is_bidi_or_format(ch: char) -> bool {
if ch == '\u{00AD}' {
return true;
}
if matches!(ch, '\u{206A}'..='\u{206F}' | '\u{FFF9}'..='\u{FFFB}') {
return true;
}
matches!(
ch,
'\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{061C}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' )
}
pub(crate) fn canonicalize(text: &str) -> Result<Cow<'_, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::StripBidi,
Step::StripInvisible(COMPARISON_STRIP),
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
Step::Zalgo(2),
Step::Nfc,
Step::ConfusablesNfcFixedPoint("latin"),
];
run(
STEPS,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
)
}
pub(crate) fn ml_normalize<'a>(
text: &'a str,
lang: Option<&str>,
emoji_style: &str,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::Demojize { only_if_cldr: true },
Step::Transliterate {
mode: crate::ErrorMode::Ignore,
only_if_lang: true,
},
Step::StripAccents,
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
];
crate::transliterate::validate_lang(lang)?;
if !matches!(emoji_style, "cldr" | "none") {
return Err(crate::ErrorRepr::InvalidEmojiStyle {
got: emoji_style.to_owned(),
});
}
run(
STEPS,
text,
&PresetCtx {
lang,
strict_iso9: false,
emoji_cldr: emoji_style == "cldr",
},
)
}
pub(crate) fn catalog_key<'a>(
text: &'a str,
lang: Option<&str>,
strict_iso9: bool,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::StripBidi,
Step::FoldCase,
Step::FixedPoint(&[
Step::Transliterate {
mode: crate::ErrorMode::Preserve,
only_if_lang: false,
},
Step::Confusables("latin"),
Step::StripAccents,
]),
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
];
crate::transliterate::validate_lang(lang)?;
run(
STEPS,
text,
&PresetCtx {
lang,
strict_iso9,
emoji_cldr: false,
},
)
}
pub(crate) fn search_key<'a>(
text: &'a str,
lang: Option<&str>,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::StripBidi,
Step::FoldCase,
Step::Transliterate {
mode: crate::ErrorMode::Preserve,
only_if_lang: false,
},
Step::StripAccents,
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
];
crate::transliterate::validate_lang(lang)?;
run(
STEPS,
text,
&PresetCtx {
lang,
strict_iso9: false,
emoji_cldr: false,
},
)
}
fn transliterate_preserving_latin_into(text: &str, lang: Option<&str>, out: &mut String) {
out.clear();
out.reserve(text.len());
let mut run = String::new(); let flush = |run: &mut String, out: &mut String| {
if !run.is_empty() {
out.push_str(&transliterate::transliterate_impl(
run,
lang,
crate::ErrorMode::Preserve,
"",
false,
false,
false,
));
run.clear();
}
};
for ch in text.chars() {
if ch.is_ascii()
|| matches!(
crate::scripts::detect_char_script(ch),
"Latin" | "Common" | "Inherited"
)
{
flush(&mut run, out);
out.push(ch);
} else {
run.push(ch);
}
}
flush(&mut run, out);
}
pub(crate) fn sort_key<'a>(
text: &'a str,
lang: Option<&str>,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::StripBidi,
Step::FoldCase,
Step::TranslitPreservingLatin,
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
Step::NfcIfNonAscii,
];
crate::transliterate::validate_lang(lang)?;
run(
STEPS,
text,
&PresetCtx {
lang,
strict_iso9: false,
emoji_cldr: false,
},
)
}
pub(crate) fn strip_format(text: &str) -> Cow<'_, str> {
const STEPS: &[Step] = &[
Step::StripBidi,
Step::StripInvisible(RENDERING_STRIP),
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
];
run(
STEPS,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
)
.expect("strip_format steps are infallible")
}
pub(crate) fn canonicalize_strict(text: &str) -> Result<Cow<'_, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::StripBidi,
Step::StripZeroWidth,
Step::StripControl,
Step::StripInvisible(COMPARISON_STRIP),
Step::Zalgo(2),
Step::ConfusablesNfcFixedPoint("latin"),
Step::CollapseWs,
Step::Nfc,
];
run(
STEPS,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
)
}
pub(crate) fn strip_obfuscation(text: &str) -> Result<Cow<'_, str>, crate::ErrorRepr> {
const STEPS: &[Step] = &[
Step::Nfkc,
Step::Zalgo(0),
Step::StripBidi,
Step::StripZeroWidth,
Step::Demojize {
only_if_cldr: false,
},
Step::StripInvisible(COMPARISON_STRIP),
Step::Confusables("latin"),
Step::StripAccents,
Step::StripControl,
Step::CollapseWs,
];
run(
STEPS,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::type_complexity)]
fn all_presets() -> Vec<(&'static str, Box<dyn Fn(&str) -> String>)> {
vec![
(
"canonicalize",
Box::new(|s| canonicalize(s).unwrap().into_owned()),
),
(
"canonicalize_strict",
Box::new(|s| canonicalize_strict(s).unwrap().into_owned()),
),
(
"strip_obfuscation",
Box::new(|s| strip_obfuscation(s).unwrap().into_owned()),
),
("strip_format", Box::new(|s| strip_format(s).into_owned())),
(
"search_key",
Box::new(|s| search_key(s, None).unwrap().into_owned()),
),
(
"sort_key",
Box::new(|s| sort_key(s, None).unwrap().into_owned()),
),
(
"catalog_key",
Box::new(|s| catalog_key(s, None, false).unwrap().into_owned()),
),
(
"ml_normalize_cldr",
Box::new(|s| ml_normalize(s, None, "cldr").unwrap().into_owned()),
),
(
"ml_normalize_none",
Box::new(|s| ml_normalize(s, None, "none").unwrap().into_owned()),
),
]
}
#[test]
fn fast_path_mask_covers_every_ascii_byte() {
for b in 0u8..128 {
let c = b as char;
let probes = [
c.to_string(),
format!("a{c}b"),
format!("{c}{c}"),
format!("{c}a"),
format!("a{c}"),
format!("a {c} b"),
];
for probe in &probes {
for (name, f) in all_presets() {
let guarded = f(probe);
let full = without_fastpath(|| f(probe));
assert_eq!(
guarded, full,
"{name}: fast path differs from full pipeline on byte {b:#04x} probe {probe:?}"
);
}
}
}
}
#[test]
#[should_panic(expected = "only Latin confusable targets")]
fn fast_path_rejects_non_latin_confusable_target() {
let _ = Actionable::for_steps(&[Step::Confusables("cyrillic")]);
}
#[test]
fn confusables_step_marks_clusters_actionable() {
assert!(
Actionable::for_steps(&[Step::Confusables("latin")]).marks,
"Confusables step must set marks (decomposed-homoglyph bypass)"
);
assert!(
Actionable::for_steps(&[Step::ConfusablesNfcFixedPoint("latin")]).marks,
"ConfusablesNfcFixedPoint step must set marks"
);
}
#[test]
fn fast_path_composes_conjoining_jamo() {
let cases = [
("\u{1100}\u{1161}", "\u{AC00}"), ("\u{AC00}\u{11A8}", "\u{AC01}"), ("\u{1100}\u{1161}\u{11A8}", "\u{AC01}"), ];
for (input, composed) in cases {
for (name, f) in all_presets() {
let guarded = f(input);
let full = without_fastpath(|| f(input));
assert_eq!(
guarded, full,
"{name}: fast path differs from full pipeline on jamo {input:?}"
);
}
assert_eq!(
strip_obfuscation(input).unwrap(),
composed,
"strip_obfuscation should NFKC-compose {input:?}"
);
}
for l in 0x1100u32..=0x1112 {
for v in 0x1161u32..=0x1175 {
let input: String = [l, v].iter().filter_map(|&c| char::from_u32(c)).collect();
let guarded = strip_obfuscation(&input).unwrap();
let full = without_fastpath(|| strip_obfuscation(&input).unwrap());
assert_eq!(guarded, full, "fast path != full on L={l:#06X} V={v:#06X}");
}
}
}
#[test]
fn fast_path_fold_case_predicate_uses_fold_table_not_is_alphabetic() {
let fold_only = Actionable {
controls: false,
collapse_ws: false,
fold_case: true,
confusables: false,
nfkc: false,
marks: false,
strip_accents: false,
zalgo_cap: None,
bidi: false,
zero_width: false,
invisible: false,
transliterate: false,
demojize: false,
};
assert!('日'.is_alphabetic());
assert!(crate::tables::case_folding_data::lookup('日').is_none());
assert!(
!acts_on_nonascii('日', fold_only, None),
"CJK is not folded, so the table-gated predicate must leave it inert"
);
assert!(crate::tables::case_folding_data::lookup('\u{24B6}').is_some());
assert!(
acts_on_nonascii('\u{24B6}', fold_only, None),
"a foldable char must be marked actionable"
);
}
#[test]
fn ascii_is_always_kept_verbatim() {
for b in 0u8..128 {
let script = crate::scripts::detect_char_script(b as char);
assert!(
matches!(script, "Latin" | "Common" | "Inherited"),
"ASCII U+{b:02X} has script {script:?} — the P-3 ASCII fast path would mis-handle it"
);
}
}
#[test]
#[ignore = "tier 3: exhaustive over the BMP + astral emoji/tag ranges — run before release"]
fn fast_path_nonascii_exhaustive() {
let presets = all_presets();
let check = |cp: u32| {
let Some(ch) = char::from_u32(cp) else { return };
if ch.is_ascii() {
return;
}
for probe in [format!("{ch}"), format!("a{ch}z"), format!("{ch} {ch}")] {
for (name, f) in &presets {
let guarded = f(&probe);
let full = without_fastpath(|| f(&probe));
assert_eq!(
guarded, full,
"{name}: fast path differs from full pipeline on U+{cp:04X} probe {probe:?}"
);
}
}
};
for cp in 0x80..=0xFFFFu32 {
check(cp);
}
for cp in (0x1D400..=0x1D7FF) .chain(0x1F000..=0x1FAFF) .chain(0xE0000..=0xE007F) .chain(0xF0000..=0xF00FF)
{
check(cp);
}
for l in 0x1100u32..=0x1112 {
for v in 0x1161u32..=0x1175 {
for t in std::iter::once(None).chain((0x11A8u32..=0x11C2).map(Some)) {
let probe: String = [Some(l), Some(v), t]
.into_iter()
.flatten()
.filter_map(char::from_u32)
.collect();
for (name, f) in &presets {
let guarded = f(&probe);
let full = without_fastpath(|| f(&probe));
assert_eq!(
guarded, full,
"{name}: fast path differs from full pipeline on jamo {probe:?}"
);
}
}
}
}
}
#[test]
fn whitespace_only_fast_path_matches_full_pipeline() {
let probes = [
"hello world ", " hello world", "hello world", " hello world ", "hello\tworld", "a\rb\nc", "the quick brown fox ", "café date",
];
for probe in probes {
let collapsed = whitespace::collapse_whitespace(probe);
for (name, f) in all_presets() {
let guarded = f(probe);
let full = without_fastpath(|| f(probe));
assert_eq!(
guarded, full,
"{name}: WhitespaceOnly fast path differs from full pipeline on {probe:?}"
);
if matches!(
name,
"canonicalize" | "canonicalize_strict" | "strip_format"
) {
assert_eq!(
guarded, collapsed,
"{name}: WhitespaceOnly result should equal collapse_whitespace on {probe:?}"
);
}
}
}
}
#[test]
fn whitespace_plus_other_action_takes_full_pipeline() {
for probe in [
"Hello World", "hello \u{0007}", "café CAFÉ ", ] {
for (name, f) in all_presets() {
let guarded = f(probe);
let full = without_fastpath(|| f(probe));
assert_eq!(
guarded, full,
"{name}: guarded != full on mixed whitespace+action input {probe:?}"
);
}
}
}
#[test]
fn preset_golden_fixtures() {
let alias_in = "Ηеllо\u{202E}\u{200B}Wo\u{0301}\u{0301}\u{0301}rld\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}";
assert_eq!(
canonicalize(alias_in).unwrap(),
"HelloW\u{f3}\u{301}rld\u{1f3f4}\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}"
);
assert_eq!(
strip_format(alias_in),
"\u{397}\u{435}ll\u{43e}Wo\u{301}\u{301}\u{301}rld\u{1f3f4}\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}"
);
assert_eq!(
canonicalize_strict(alias_in).unwrap(),
"HelloW\u{f3}\u{301}rld\u{1f3f4}\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}"
);
assert_eq!(search_key("CAFÉ\u{200B} ИМЯ", None).unwrap(), "cafe imya");
assert_eq!(
catalog_key("Война и МИР\u{00AD}", None, false).unwrap(),
"voyna i mir"
);
assert_eq!(sort_key("Über ИМЯ", None).unwrap(), "\u{fc}ber imya");
assert_eq!(
ml_normalize("Café \u{1F600} ИМЯ", Some("ru"), "cldr").unwrap(),
"cafe grinning face imya"
);
assert_eq!(
strip_obfuscation("Ηеllо\u{202E}Wоrld \u{1F600}").unwrap(),
"HelloWorld grinning face"
);
}
#[test]
fn run_executes_steps_in_order_with_pingpong() {
let steps = &[Step::StripBidi, Step::FoldCase, Step::CollapseWs];
let ctx = PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
};
let got = run(steps, " HE\u{202E}LLO ", &ctx).unwrap();
let want = whitespace::collapse_whitespace(&case_fold::fold_case_impl(&strip_bidi(
" HE\u{202E}LLO ",
)));
assert_eq!(got, want);
}
#[test]
fn run_empty_steps_is_identity() {
let ctx = PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
};
assert_eq!(run(&[], "café \u{202E}x", &ctx).unwrap(), "café \u{202E}x");
}
#[test]
fn run_skips_noop_steps_without_corrupting_buffers() {
let ctx = PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
};
let steps = &[
Step::FoldCase,
Step::NfcIfNonAscii,
Step::Transliterate {
mode: crate::ErrorMode::Preserve,
only_if_lang: true,
},
Step::CollapseWs,
];
assert_eq!(
run(steps, " HELLO WORLD ", &ctx).unwrap(),
"hello world"
);
}
#[test]
fn test_presets_do_not_mangle_path_separators() {
assert_eq!(
canonicalize("https://example.com/path").unwrap(),
"https://example.com/path"
);
assert_eq!(canonicalize("../etc/passwd").unwrap(), "../etc/passwd");
assert_eq!(canonicalize_strict("a/b\\c").unwrap(), "a/b\\c");
}
#[test]
fn test_strip_bidi_soft_hyphen() {
assert_eq!(strip_bidi("pass\u{00AD}word"), "password");
}
#[test]
fn test_strip_bidi_arabic_letter_mark() {
assert_eq!(strip_bidi("hello\u{061C}world"), "helloworld");
}
#[test]
fn test_strip_bidi_marks() {
assert_eq!(strip_bidi("a\u{200E}b"), "ab"); assert_eq!(strip_bidi("a\u{200F}b"), "ab"); }
#[test]
fn test_strip_bidi_embeddings_overrides() {
assert_eq!(strip_bidi("a\u{202A}b"), "ab"); assert_eq!(strip_bidi("a\u{202B}b"), "ab"); assert_eq!(strip_bidi("a\u{202C}b"), "ab"); assert_eq!(strip_bidi("a\u{202D}b"), "ab"); assert_eq!(strip_bidi("a\u{202E}b"), "ab"); }
#[test]
fn test_strip_bidi_isolates() {
assert_eq!(strip_bidi("a\u{2066}b"), "ab"); assert_eq!(strip_bidi("a\u{2067}b"), "ab"); assert_eq!(strip_bidi("a\u{2068}b"), "ab"); assert_eq!(strip_bidi("a\u{2069}b"), "ab"); }
#[test]
fn test_strip_bidi_all_at_once() {
let all_bidi = "\u{00AD}\u{061C}\u{200E}\u{200F}\
\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\
\u{2066}\u{2067}\u{2068}\u{2069}";
assert_eq!(strip_bidi(&format!("x{all_bidi}y")), "xy");
assert_eq!(all_bidi.chars().count(), 13);
}
#[test]
fn test_strip_bidi_preserves_normal() {
assert_eq!(strip_bidi("hello world"), "hello world");
assert_eq!(strip_bidi("café"), "café");
assert_eq!(strip_bidi("مرحبا"), "مرحبا");
}
#[test]
fn strip_bidi_has_no_ascii_targets() {
for cp in 0u8..=0x7F {
assert!(
!is_bidi_or_format(cp as char),
"ASCII U+{cp:02X} must not be a bidi/format target"
);
}
}
#[test]
fn test_canonicalize_homoglyph() {
let result = canonicalize("\u{0440}\u{0430}ypal").unwrap();
assert_eq!(result, "paypal");
}
#[test]
fn test_canonicalize_bidi() {
let result = canonicalize("admin\u{202E}user").unwrap();
assert_eq!(result, "adminuser");
}
#[test]
fn test_canonicalize_arabic_letter_mark() {
let result = canonicalize("admin\u{061C}user").unwrap();
assert_eq!(result, "adminuser");
}
#[test]
fn test_canonicalize_invisible_math_operators() {
let result = canonicalize("pass\u{2061}word").unwrap();
assert_eq!(result, "password");
}
#[test]
fn test_canonicalize_soft_hyphen() {
let result = canonicalize("pass\u{00AD}word").unwrap();
assert_eq!(result, "password");
}
#[test]
fn test_canonicalize_zwsp() {
let result = canonicalize("admin\u{200B}user").unwrap();
assert_eq!(result, "adminuser");
}
#[test]
fn test_canonicalize_idempotent_on_invisible_separated_mark() {
for sep in ['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'] {
let input = format!("a{sep}\u{0301}b");
let once = canonicalize(&input).unwrap();
assert_eq!(once, "\u{00E1}b", "sep {sep:?} should compose to á+b");
assert_eq!(
once,
canonicalize(&once).unwrap(),
"sep {sep:?} not idempotent"
);
}
}
#[test]
fn test_presets_idempotent_on_duplicate_combining_marks() {
let input = "c\u{0327}\u{0327}"; for preset in [
canonicalize(input).unwrap(),
canonicalize_strict(input).unwrap(),
] {
assert_eq!(preset, "c", "should fold to a bare c in one call");
}
assert_eq!(canonicalize("c").unwrap(), canonicalize(input).unwrap());
assert_eq!(
canonicalize_strict("c").unwrap(),
canonicalize_strict(input).unwrap()
);
}
#[test]
fn test_sort_key_idempotent_on_invisible_separated_mark() {
for sep in ['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'] {
let input = format!("a{sep}\u{0301}b");
let once = sort_key(&input, None).unwrap();
assert_eq!(once, "\u{00E1}b");
assert_eq!(
once,
sort_key(&once, None).unwrap(),
"sep {sep:?} not idempotent"
);
}
}
#[test]
fn test_key_presets_idempotent_on_case_pair_transliteration() {
let input = "\u{1CB1}"; for once in [
sort_key(input, None).unwrap(),
search_key(input, None).unwrap(),
catalog_key(input, None, false).unwrap(),
] {
assert_eq!(once, "he", "first pass should fully transliterate");
}
assert_eq!(
sort_key(input, None).unwrap(),
sort_key("he", None).unwrap()
);
assert_eq!(
search_key(input, None).unwrap(),
search_key("he", None).unwrap()
);
assert_eq!(
catalog_key(input, None, false).unwrap(),
catalog_key("he", None, false).unwrap()
);
}
#[test]
fn test_ml_normalize_basic() {
let result = ml_normalize("Café Résumé", None, "cldr").unwrap();
assert_eq!(result, "cafe resume");
}
#[test]
fn test_ml_normalize_ligature() {
let result = ml_normalize("\u{FB01}lter", None, "cldr").unwrap();
assert_eq!(result, "filter");
}
#[test]
fn test_catalog_key_dedup() {
let a = catalog_key("Café", None, false).unwrap();
let b = catalog_key("café", None, false).unwrap();
let c = catalog_key("CAFÉ", None, false).unwrap();
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn test_catalog_key_iso9() {
let result = catalog_key("\u{0419}\u{043E}\u{0433}\u{0430}", None, true).unwrap();
assert_eq!(result, "joga");
}
#[test]
fn test_catalog_key_idempotent_on_confusable_cascades() {
for (input, want) in [
("\u{2204}", "e"), ("\u{2224}", "l"), ("\u{2226}", "ll"), ("\u{2241}", "~"), ("\u{1D14}", "eo"), ("\u{256A}", "!"), ("\u{2797}", "/"), ] {
let once = catalog_key(input, None, false).unwrap();
assert_eq!(
once, want,
"catalog_key({input:?}) should fold fully in one call"
);
assert_eq!(
once,
catalog_key(&once, None, false).unwrap(),
"catalog_key not idempotent on {input:?}"
);
}
}
#[test]
fn test_search_key_accent_insensitive() {
let a = search_key("Café", None).unwrap();
let b = search_key("cafe", None).unwrap();
let c = search_key("CAFÉ", None).unwrap();
assert_eq!(a, "cafe");
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn test_search_key_cyrillic() {
assert_eq!(search_key("Москва", None).unwrap(), "moskva");
}
#[test]
fn test_search_key_greek() {
assert_eq!(search_key("ΩMEGA", None).unwrap(), "omega");
}
#[test]
fn test_sort_key_preserves_accents() {
assert_eq!(sort_key("Über", None).unwrap(), "über");
assert_eq!(sort_key("naïve", None).unwrap(), "naïve");
assert_eq!(sort_key("Köln", None).unwrap(), "köln");
assert_eq!(sort_key("Straße", None).unwrap(), "strasse");
}
#[test]
fn test_sort_key_folds_uppercase_emitted_by_transliteration() {
let once = sort_key("\u{103C8}", None).unwrap();
assert_eq!(once, "auramazda");
assert_eq!(sort_key(&once, None).unwrap(), once);
}
#[test]
fn test_sort_key_cyrillic() {
assert_eq!(sort_key("Война и мир", None).unwrap(), "voyna i mir");
}
#[test]
fn test_sort_key_vs_search_key() {
assert_eq!(
sort_key("Москва", None).unwrap(),
search_key("Москва", None).unwrap()
);
assert_eq!(search_key("Über", None).unwrap(), "uber");
assert_ne!(
sort_key("Über", None).unwrap(),
search_key("Über", None).unwrap()
);
}
#[test]
fn test_sort_key_lang_does_not_expand_latin_accents() {
assert_eq!(sort_key("Über", Some("de")).unwrap(), "über");
assert_eq!(search_key("Über", Some("de")).unwrap(), "ueber");
}
#[test]
fn test_sort_key_mixed_script_preserves_latin_folds_other() {
assert_eq!(sort_key("Ω café", None).unwrap(), "o café");
}
#[test]
fn test_key_functions_strip_bidi_and_soft_hyphen() {
for (stored, clean) in [
("pass\u{00AD}word", "password"), ("user\u{202E}txt", "usertxt"), ("a\u{200E}b", "ab"), ("x\u{061C}y", "xy"), ] {
assert_eq!(
search_key(stored, None).unwrap(),
search_key(clean, None).unwrap(),
"search_key must collide for {stored:?} vs {clean:?}"
);
assert_eq!(
catalog_key(stored, None, false).unwrap(),
catalog_key(clean, None, false).unwrap(),
"catalog_key must collide for {stored:?} vs {clean:?}"
);
assert_eq!(
sort_key(stored, None).unwrap(),
sort_key(clean, None).unwrap(),
"sort_key must collide for {stored:?} vs {clean:?}"
);
}
}
#[test]
fn test_strip_format_basic() {
assert_eq!(strip_format("hello world"), "hello world");
assert_eq!(strip_format("hello\x00world"), "helloworld");
assert_eq!(strip_format("hello\u{200B}world"), "helloworld");
}
#[test]
fn test_strip_format_strips_bidi() {
assert_eq!(strip_format("admin\u{202E}user"), "adminuser");
assert_eq!(strip_format("pass\u{00AD}word"), "password");
assert_eq!(strip_format("hello\u{061C}world"), "helloworld");
}
#[test]
fn test_strip_format_idempotent_on_vs_after_blank_render() {
for input in [
"\u{2800}\u{FE0F}x", "\u{115F}\u{FE0F}x", "\u{0000}\u{FE0F}x", "\u{200B}\u{FE0F}x", ] {
let once = strip_format(input);
assert_eq!(once, "x", "input {input:?} should reduce to \"x\"");
assert_eq!(strip_format(&once), once, "not idempotent on {input:?}");
}
}
#[test]
fn test_canonicalize_strict_clean_text() {
assert_eq!(
canonicalize_strict("Hello, world!").unwrap(),
"Hello, world!"
);
}
#[test]
fn test_canonicalize_strict_preserves_script() {
let result = canonicalize_strict("Москва").unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_canonicalize_strict_strips_zalgo() {
let mut zalgo = String::from("hello");
for _ in 0..20 {
zalgo.push('\u{0300}');
}
zalgo.push_str(" world");
let result = canonicalize_strict(&zalgo).unwrap();
assert!(result.len() < zalgo.len());
assert!(result.contains("world"));
}
#[test]
fn test_canonicalize_strict_strips_bidi() {
assert_eq!(
canonicalize_strict("admin\u{202E}user").unwrap(),
"adminuser"
);
}
#[test]
fn test_canonicalize_strict_strips_zero_width() {
assert_eq!(canonicalize_strict("pass\u{200B}word").unwrap(), "password");
}
#[test]
fn test_canonicalize_strict_preserves_accents() {
assert_eq!(canonicalize_strict("café").unwrap(), "café");
assert_eq!(canonicalize_strict("résumé").unwrap(), "résumé");
}
#[test]
fn test_canonicalize_strict_homoglyph() {
let result = canonicalize_strict("p\u{0430}ypal").unwrap();
assert_eq!(result, "paypal");
}
mod proptest_properties {
use super::*;
use proptest::prelude::*;
const SPECIAL: &[char] = &[
'\u{200E}',
'\u{200F}',
'\u{202A}',
'\u{202B}',
'\u{202C}',
'\u{202D}',
'\u{202E}',
'\u{061C}',
'\u{2066}',
'\u{2067}',
'\u{2068}',
'\u{2069}',
'\u{00AD}',
'\u{200B}',
'\u{200C}',
'\u{200D}',
'\u{2060}',
'\u{FEFF}',
'\u{0301}',
'\u{0300}',
'\u{0489}',
'\u{0327}',
'\u{0308}',
'\u{0430}',
'\u{0440}',
'\u{0441}',
'\u{0435}',
'\u{043E}',
'\u{FF41}',
'\u{1F452}',
];
fn adversarial() -> impl Strategy<Value = String> {
let special = proptest::sample::select(SPECIAL.to_vec());
proptest::collection::vec(
prop_oneof![4 => any::<char>(), 3 => special, 2 => prop::char::range('a', 'z')],
0..40,
)
.prop_map(|cs| cs.into_iter().collect())
}
fn fastpath_gen() -> impl Strategy<Value = String> {
let edge = prop::sample::select(vec![
'a',
'b',
'Z',
'A',
'0',
'9',
'.',
'-',
'_',
' ',
'\t',
'\n',
'\r',
'\u{0B}',
'\u{0C}',
'\u{1C}',
'\u{00}',
'\u{07}',
'\u{1B}',
'\u{7F}',
'"',
'`',
'|',
'é',
'ñ',
'ø',
'þ',
'Ω',
'Σ',
'日',
'本',
'한',
'글',
'м',
'и',
'р',
'ا',
'\u{0080}',
'\u{00A0}',
'\u{0301}',
'\u{200B}',
'\u{202E}',
'\u{1F600}',
'\u{1F3F4}',
'\u{2800}',
]);
prop_oneof![
proptest::collection::vec(edge, 0..24)
.prop_map(|cs| cs.into_iter().collect::<String>())
.boxed(),
adversarial().boxed(),
jamo_seq().boxed(),
]
}
fn confusable_cascade() -> impl Strategy<Value = String> {
const TRIGGERS: &[char] = &[
'\u{2204}', '\u{2224}', '\u{2226}', '\u{2241}',
'\u{1D14}', '\u{256A}', '\u{2797}',
'\u{0338}', '\u{2203}', '\u{2223}', '\u{2225}', '\u{223C}',
];
let trig = proptest::sample::select(TRIGGERS.to_vec());
prop_oneof![
proptest::collection::vec(trig, 0..12)
.prop_map(|cs| cs.into_iter().collect::<String>())
.boxed(),
adversarial().boxed(),
]
}
fn jamo_seq() -> impl Strategy<Value = String> {
let lead = (0x1100u32..=0x1112).prop_map(|c| char::from_u32(c).unwrap());
let vowel =
prop::option::of((0x1161u32..=0x1175).prop_map(|c| char::from_u32(c).unwrap()));
let trail =
prop::option::of((0x11A8u32..=0x11C2).prop_map(|c| char::from_u32(c).unwrap()));
(lead, vowel, trail).prop_map(|(l, v, t)| {
let mut s = String::new();
s.push(l);
if let Some(v) = v {
s.push(v);
}
if let Some(t) = t {
s.push(t);
}
s
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn fast_path_equivalence(s in fastpath_gen()) {
for (name, f) in all_presets() {
let guarded = f(&s);
let full = without_fastpath(|| f(&s));
prop_assert_eq!(&guarded, &full, "{} fast-path != full on {:?}", name, s);
}
}
#[test]
fn canonicalize_idempotent(s in adversarial()) {
let once = canonicalize(&s).unwrap();
let twice = canonicalize(&once).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn sort_key_idempotent(s in adversarial()) {
let once = sort_key(&s, None).unwrap();
let twice = sort_key(&once, None).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn search_key_idempotent(s in adversarial()) {
let once = search_key(&s, None).unwrap();
let twice = search_key(&once, None).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn catalog_key_idempotent(s in adversarial()) {
let once = catalog_key(&s, None, false).unwrap();
let twice = catalog_key(&once, None, false).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn catalog_key_idempotent_on_cascades(s in confusable_cascade()) {
let once = catalog_key(&s, None, false).unwrap();
let twice = catalog_key(&once, None, false).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn ml_normalize_idempotent_emoji_none(
s in adversarial(),
lang in prop::option::of(prop::sample::select(vec!["de", "ru", "ja"])),
) {
let once = ml_normalize(&s, lang, "none").unwrap();
let twice = ml_normalize(&once, lang, "none").unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn ml_normalize_postconditions_all_modes(
s in adversarial(),
lang in prop::option::of(prop::sample::select(vec!["de", "ru", "ja"])),
style in prop::sample::select(vec!["cldr", "none"]),
) {
let out = ml_normalize(&s, lang, style).unwrap();
prop_assert!(
case_fold::fold_case_impl(&out) == out,
"fold_case not a fixed point of ml_normalize output: {out:?}"
);
prop_assert_eq!(out.trim(), &out, "not trimmed: {:?}", out);
prop_assert!(!out.contains(" "), "double space in {out:?}");
}
#[test]
fn strip_obfuscation_idempotent(s in adversarial()) {
let once = strip_obfuscation(&s).unwrap();
let twice = strip_obfuscation(&once).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn canonicalize_strict_idempotent(s in adversarial()) {
let once = canonicalize_strict(&s).unwrap();
let twice = canonicalize_strict(&once).unwrap();
prop_assert_eq!(&once, &twice);
}
#[test]
fn strip_format_idempotent(s in adversarial()) {
let once = strip_format(&s);
prop_assert_eq!(&once, &strip_format(&once));
}
#[test]
fn strip_bidi_idempotent(s in adversarial()) {
let once = strip_bidi(&s);
prop_assert_eq!(&once, &strip_bidi(&once));
}
#[test]
fn no_bidi_after_strip_bidi(s in adversarial()) {
prop_assert!(!strip_bidi(&s).chars().any(is_bidi_or_format));
}
#[test]
fn no_bidi_after_canonicalize(s in adversarial()) {
prop_assert!(!canonicalize(&s).unwrap().chars().any(is_bidi_or_format));
}
#[test]
fn no_bidi_after_strip_obfuscation(s in adversarial()) {
prop_assert!(!strip_obfuscation(&s).unwrap().chars().any(is_bidi_or_format));
}
#[test]
fn no_bidi_after_canonicalize_strict(s in adversarial()) {
prop_assert!(!canonicalize_strict(&s).unwrap().chars().any(is_bidi_or_format));
}
}
}
}