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,
};
pub(crate) 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),
DropRepeatedMarks,
FoldCase,
StripAccents,
Transliterate {
mode: crate::ErrorMode,
only_if_lang: bool,
},
TranslitPreservingLatin,
Confusables(&'static str, crate::confusables::DigitPolicy),
ConfusablesNfcFixedPoint(&'static str, crate::confusables::DigitPolicy),
ConfusablesMarkFixedPoint(&'static str, crate::confusables::DigitPolicy),
FixedPoint(&'static [Step]),
Demojize {
only_if_cldr: bool,
policy: crate::emoji::NamePolicy,
},
}
macro_rules! static_steps {
(
$(#[$meta:meta])*
const $steps:ident;
fn $apply:ident;
[$($step:expr),* $(,)?]
) => {
$(#[$meta])*
const $steps: &[Step] = &[$($step),*];
const MASK: Actionable = Actionable::for_steps($steps);
#[inline]
fn $apply(input: &str, ctx: &PresetCtx) -> Result<String, crate::ErrorRepr> {
let mut cur = input.to_owned();
let mut scratch = String::new();
$(
if apply_into($step, &cur, ctx, &mut scratch)? {
std::mem::swap(&mut cur, &mut scratch);
}
)*
Ok(cur)
}
};
}
#[allow(clippy::inline_always)]
#[inline(always)]
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)?;
if out.len() > crate::limits::MAX_NORMALIZE_OUTPUT_BYTES {
return Err(crate::ErrorRepr::NormalizeOutputTooLarge {
size: out.len(),
max: crate::limits::MAX_NORMALIZE_OUTPUT_BYTES,
});
}
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::DropRepeatedMarks => Ok(zalgo::drop_repeated_marks_into(input, out)),
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, digits) => {
confusables::normalize_confusables_into(input, target, digits, out)?;
Ok(true)
}
Step::ConfusablesNfcFixedPoint(target, digits) => {
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, digits, &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::ConfusablesMarkFixedPoint(target, digits) => {
let mut cur = input.to_owned();
let mut conf = String::new();
let mut nxt = String::new();
let mut stripped = String::new();
for _ in 0..CONFUSABLE_FIXED_POINT_ITERS {
let mut cur_is_nfc = false;
for _ in 0..CONFUSABLE_FIXED_POINT_ITERS {
confusables::normalize_confusables_into(&cur, target, digits, &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;
}
zalgo::strip_cross_script_marks_into(&cur, &mut stripped);
if stripped == cur {
break;
}
std::mem::swap(&mut cur, &mut stripped);
}
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,
policy,
} => {
if only_if_cldr && !ctx.emoji_cldr {
return Ok(false);
}
emoji::demojize_rust_into(input, false, policy, 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 {
const 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,
};
let mut idx = 0;
while idx < steps.len() {
let step = steps[idx];
idx += 1;
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, _)
| Step::ConfusablesMarkFixedPoint(target, _) => {
assert!(
matches!(target.as_bytes(), b"latin"),
"fast-path guard supports only Latin confusable targets; the \
Cyrillic map rewrites different sources (ASCII A/B/a/b), so a \
non-Latin target would let the guard skip input the fold \
changes — make the guard target-aware first"
);
m.confusables = true;
m.marks = true;
}
Step::FixedPoint(inner) => {
let mut i = 0;
while i < inner.len() {
assert!(
!matches!(inner[i], Step::FixedPoint(_)),
"FixedPoint inner list must not contain a nested FixedPoint"
);
i += 1;
}
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::DropRepeatedMarks => m.marks = true,
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
}
const 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(if a < b { a } else { b }),
(Some(a), None) => Some(a),
(None, b) => 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)
|| invisibles::is_default_ignorable_format(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)?))
}
#[inline]
fn run_static<'a>(
mask: Actionable,
text: &'a str,
ctx: &PresetCtx,
apply: impl FnOnce(&str, &PresetCtx) -> Result<String, crate::ErrorRepr>,
) -> 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 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(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 crate::scripts::is_bidi_control(ch) {
return true;
}
if ch == '\u{00AD}' {
return true;
}
matches!(ch, '\u{206A}'..='\u{206F}' | '\u{FFF9}'..='\u{FFFB}')
}
pub(crate) fn canonicalize(text: &str) -> Result<Cow<'_, str>, crate::ErrorRepr> {
static_steps! {
const STEPS;
fn apply;
[
Step::Nfkc,
Step::StripBidi,
Step::StripInvisible(COMPARISON_STRIP),
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
Step::DropRepeatedMarks,
Step::Zalgo(crate::zalgo::DEFAULT_MAX_MARKS),
Step::Nfc,
Step::ConfusablesNfcFixedPoint("latin", crate::confusables::DigitPolicy::Numeric),
Step::DropRepeatedMarks,
]
}
run_static(
MASK,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
apply,
)
}
pub(crate) fn ml_normalize<'a>(
text: &'a str,
lang: Option<&str>,
emoji_style: &str,
fold_case: bool,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
const STEPS: &[Step; 9] = &[
Step::Nfkc,
Step::Demojize {
only_if_cldr: true,
policy: crate::emoji::NamePolicy {
skip_tr39_claimed: false,
skip_non_emoji: true,
},
},
Step::Transliterate {
mode: crate::ErrorMode::Ignore,
only_if_lang: true,
},
Step::StripAccents,
Step::Demojize {
only_if_cldr: true,
policy: crate::emoji::NamePolicy {
skip_tr39_claimed: false,
skip_non_emoji: true,
},
},
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
];
const STEPS_NO_FOLD: [Step; 8] = without_fold_case(STEPS);
crate::transliterate::validate_lang(lang)?;
if !matches!(emoji_style, "cldr" | "none") {
return Err(crate::ErrorRepr::InvalidEmojiStyle {
got: emoji_style.to_owned(),
});
}
let steps: &[Step] = if fold_case { STEPS } else { &STEPS_NO_FOLD };
run(
steps,
text,
&PresetCtx {
lang,
strict_iso9: false,
emoji_cldr: emoji_style == "cldr",
},
)
}
const fn without_fold_case(steps: &[Step; 9]) -> [Step; 8] {
let mut out = [Step::Nfkc; 8];
let mut read = 0;
let mut write = 0;
while read < steps.len() {
if !matches!(steps[read], Step::FoldCase) {
assert!(
write < 8,
"ml_normalize's step list must contain exactly one Step::FoldCase"
);
out[write] = steps[read];
write += 1;
}
read += 1;
}
assert!(
write == 8,
"ml_normalize's step list must contain exactly one Step::FoldCase"
);
out
}
pub(crate) fn catalog_key<'a>(
text: &'a str,
lang: Option<&str>,
strict_iso9: bool,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
static_steps! {
const STEPS;
fn apply;
[
Step::Nfkc,
Step::StripBidi,
Step::StripInvisible(COMPARISON_STRIP),
Step::FoldCase,
Step::FixedPoint(&[
Step::Transliterate {
mode: crate::ErrorMode::Preserve,
only_if_lang: false,
},
Step::Confusables("latin", crate::confusables::DigitPolicy::Numeric),
Step::StripAccents,
]),
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
]
}
crate::transliterate::validate_lang(lang)?;
run_static(
MASK,
text,
&PresetCtx {
lang,
strict_iso9,
emoji_cldr: false,
},
apply,
)
}
pub(crate) fn search_key<'a>(
text: &'a str,
lang: Option<&str>,
) -> Result<Cow<'a, str>, crate::ErrorRepr> {
static_steps! {
const STEPS;
fn apply;
[
Step::Nfkc,
Step::StripBidi,
Step::StripInvisible(COMPARISON_STRIP),
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_static(
MASK,
text,
&PresetCtx {
lang,
strict_iso9: false,
emoji_cldr: false,
},
apply,
)
}
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> {
static_steps! {
const STEPS;
fn apply;
[
Step::Nfkc,
Step::StripBidi,
Step::StripInvisible(COMPARISON_STRIP),
Step::FoldCase,
Step::TranslitPreservingLatin,
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
Step::DropRepeatedMarks,
Step::Zalgo(crate::zalgo::DEFAULT_MAX_MARKS),
Step::NfcIfNonAscii,
]
}
crate::transliterate::validate_lang(lang)?;
run_static(
MASK,
text,
&PresetCtx {
lang,
strict_iso9: false,
emoji_cldr: false,
},
apply,
)
}
pub(crate) fn strip_format(text: &str) -> Cow<'_, str> {
static_steps! {
const STEPS;
fn apply;
[
Step::StripBidi,
Step::StripInvisible(RENDERING_STRIP),
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
]
}
run_static(
MASK,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
apply,
)
.expect("strip_format steps are infallible")
}
pub(crate) fn canonicalize_strict(text: &str) -> Result<Cow<'_, str>, crate::ErrorRepr> {
static_steps! {
const STEPS;
fn apply;
[
Step::Nfkc,
Step::StripBidi,
Step::StripZeroWidth,
Step::StripControl,
Step::StripInvisible(COMPARISON_STRIP),
Step::ConfusablesMarkFixedPoint("latin", crate::confusables::DigitPolicy::Numeric),
Step::DropRepeatedMarks,
Step::Zalgo(crate::zalgo::DEFAULT_MAX_MARKS),
Step::CollapseWs,
Step::Nfc,
]
}
run_static(
MASK,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
apply,
)
}
pub(crate) fn strip_obfuscation(text: &str) -> Result<Cow<'_, str>, crate::ErrorRepr> {
static_steps! {
const STEPS;
fn apply;
[
Step::Nfkc,
Step::Zalgo(0),
Step::StripBidi,
Step::StripZeroWidth,
Step::Demojize {
only_if_cldr: false,
policy: crate::emoji::NamePolicy {
skip_tr39_claimed: true,
skip_non_emoji: true,
},
},
Step::StripInvisible(COMPARISON_STRIP),
Step::Confusables("latin", crate::confusables::DigitPolicy::Numeric),
Step::StripAccents,
Step::StripControl,
Step::CollapseWs,
]
}
run_static(
MASK,
text,
&PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
},
apply,
)
}
#[cfg(test)]
mod tests {
#[test]
fn the_confusables_step_carries_and_applies_a_digit_policy() {
use crate::confusables::DigitPolicy;
let ctx = PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
};
let input = "\u{0966}";
let mut out = String::new();
for (policy, expected) in [
(DigitPolicy::Numeric, "0"),
(DigitPolicy::Tr39, "o"),
(DigitPolicy::Preserve, "\u{0966}"),
] {
apply_into(Step::Confusables("latin", policy), input, &ctx, &mut out)
.expect("the latin target is valid");
assert_eq!(out, expected, "{policy:?} on U+0966");
}
}
#[test]
fn no_shipped_preset_uses_a_non_default_digit_policy() {
let src = include_str!("presets.rs");
let body = &src[..src.find("\nmod tests {").unwrap_or(src.len())];
let offenders: Vec<&str> = body
.lines()
.filter(|l| l.contains("DigitPolicy::Tr39") || l.contains("DigitPolicy::Preserve"))
.collect();
assert!(
offenders.is_empty(),
"a preset names a digit policy other than Numeric, which changes key output: \
{offenders:?}",
);
assert!(
body.contains("DigitPolicy::Numeric"),
"no preset names a digit policy"
);
}
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", true).unwrap().into_owned()),
),
(
"ml_normalize_none",
Box::new(|s| ml_normalize(s, None, "none", true).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",
crate::confusables::DigitPolicy::Numeric,
)]);
}
#[test]
fn confusables_step_marks_clusters_actionable() {
assert!(
Actionable::for_steps(&[Step::Confusables(
"latin",
crate::confusables::DigitPolicy::Numeric
)])
.marks,
"Confusables step must set marks (decomposed-homoglyph bypass)"
);
assert!(
Actionable::for_steps(&[Step::ConfusablesNfcFixedPoint(
"latin",
crate::confusables::DigitPolicy::Numeric
)])
.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 strip_obfuscation_folds_the_rows_tr39_also_claims() {
assert_eq!(
strip_obfuscation("\u{20AC}xample.com").unwrap(),
"example.com"
);
for spoof in [
"ex\u{2010}ample.com",
"ex\u{2011}ample.com",
"ex\u{2212}ample.com",
] {
assert_eq!(
strip_obfuscation(spoof).unwrap(),
strip_obfuscation("ex-ample.com").unwrap(),
"{spoof:?}"
);
}
}
#[test]
fn standalone_demojize_still_names_the_claimed_rows() {
let mut out = String::new();
crate::emoji::demojize_rust_into(
"I \u{2764} \u{20AC}5",
false,
crate::emoji::NamePolicy::NAME_EVERYTHING,
&mut out,
);
assert_eq!(out, "I red heart euro 5");
}
#[test]
fn a_skipped_row_does_not_fuse_onto_the_preceding_name() {
for (input, expected) in [
("\u{1F452}\u{20AC}", "woman's hat e"),
("\u{1F452}\u{2211}", "woman's hat s"),
("\u{1F452}\u{2200}", "woman's hat a"),
] {
assert_eq!(strip_obfuscation(input).unwrap(), expected, "{input:?}");
}
assert_eq!(
strip_obfuscation("\u{1F452}\u{2010}").unwrap(),
"woman's hat-"
);
assert_eq!(
strip_obfuscation("\u{1F452}\u{20AC}").unwrap(),
strip_obfuscation("\u{1F452} \u{20AC}").unwrap()
);
}
#[test]
fn emoji_name_punctuation_is_still_folded() {
let once = strip_obfuscation("\u{1F452}").unwrap();
assert_eq!(once, "woman's hat");
assert_eq!(strip_obfuscation(&once).unwrap(), once);
}
#[test]
fn canonicalize_strict_drops_a_cross_script_mark() {
assert_eq!(
canonicalize_strict("exa\u{651}mple.com").unwrap(),
canonicalize_strict("example.com").unwrap()
);
assert_eq!(
canonicalize_strict("exa\u{E31}mple.com").unwrap(),
canonicalize_strict("example.com").unwrap()
);
}
#[test]
fn canonicalize_strict_keeps_ordinary_diacritics() {
for text in ["caf\u{e9}", "na\u{ef}ve", "Vi\u{1ec7}t Nam"] {
assert_eq!(canonicalize_strict(text).unwrap(), text, "{text:?}");
}
}
#[test]
fn canonicalize_strict_folds_what_the_mark_strip_exposes() {
for (input, expected) in [("C\u{489}\u{327}", "C"), ("c\u{489}\u{327}", "c")] {
let once = canonicalize_strict(input).unwrap();
assert_eq!(once, expected, "{input:?}");
assert_eq!(
canonicalize_strict(&once).unwrap(),
once,
"{input:?} is not a fixed point"
);
}
}
#[test]
fn the_blocking_starters_are_a_class_not_one_character() {
for blocker in ['\u{488}', '\u{489}', '\u{7A6}', '\u{7AF}'] {
let input = format!("C{blocker}\u{327}");
let once = canonicalize_strict(&input).unwrap();
assert_eq!(
canonicalize_strict(&once).unwrap(),
once,
"U+{:04X} leaves a non-fixed point",
blocker as u32
);
}
}
#[test]
fn canonicalize_does_not_get_the_cross_script_rule() {
let eclipsed = "exa\u{651}mple.com";
assert_ne!(
canonicalize(eclipsed).unwrap(),
canonicalize("example.com").unwrap()
);
}
#[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}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}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", true).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 sort_key_zalgo_cap_runs_after_the_zero_width_strip() {
let input = "\u{300}\u{301}\u{302}\u{200b}\u{303}";
let once = sort_key(input, None).unwrap();
let twice = sort_key(&once, None).unwrap();
assert_eq!(once, twice, "sort_key must be idempotent on {input:?}");
let marks = once
.chars()
.filter(|c| unicode_normalization::char::canonical_combining_class(*c) == 230)
.count();
assert_eq!(marks, crate::zalgo::DEFAULT_MAX_MARKS);
}
#[test]
fn the_repeat_step_is_a_no_op_when_nothing_repeats() {
let ctx = PresetCtx {
lang: None,
strict_iso9: false,
emoji_cldr: false,
};
let mut out = String::new();
for input in [
"hello",
"caf\u{e9}",
"cafe\u{301}",
"Vi\u{1ec7}t",
"\u{e1}\u{300}",
] {
out.clear();
let wrote = apply_into(Step::DropRepeatedMarks, input, &ctx, &mut out).unwrap();
assert!(
!wrote,
"{input:?} has no repeated mark but the step claimed a rewrite"
);
assert!(
out.is_empty(),
"{input:?}: a no-op must not touch the scratch buffer"
);
}
for (input, expected) in [
("a\u{301}\u{301}", "\u{e1}"),
("\u{e1}\u{301}", "\u{e1}"),
] {
out.clear();
let wrote = apply_into(Step::DropRepeatedMarks, input, &ctx, &mut out).unwrap();
assert!(wrote, "{input:?} repeats a mark in NFD");
assert_eq!(out, expected, "{input:?}");
}
}
#[test]
fn every_zalgo_cap_runs_after_its_invisible_strip() {
let src = include_str!("presets.rs");
let mut checked = 0;
for (name, body) in step_arrays(src) {
for step in ["Step::Zalgo(", "Step::DropRepeatedMarks"] {
let Some(z) = body.find(step) else {
continue;
};
if body[z..].starts_with("Step::Zalgo(0)") {
continue;
}
let before = &body[..z];
for remover in MARK_RUN_SPLITTERS {
assert!(
before.contains(remover),
"{name}: {step} runs before {remover}, which can delete a \
character from between two mark runs — the runs then merge on the \
next pass and the mark rule sees a different grouping (#121, #850, \
#862, #835)",
);
}
checked += 1;
}
}
assert!(
checked >= 6,
"expected the cap AND the repeat rule in each of canonicalize, \
canonicalize_strict and sort_key; found {checked} — has the parser drifted?",
);
}
#[test]
fn zalgo_zero_is_order_independent() {
let split = "a\u{301}\u{301}\u{301}\u{200b}\u{301}b";
let joined = "a\u{301}\u{301}\u{301}\u{301}b";
let once = strip_obfuscation(split).unwrap();
assert_eq!(once, strip_obfuscation(joined).unwrap());
assert_eq!(once, strip_obfuscation(&once).unwrap());
assert!(
!once.contains('\u{301}'),
"cap 0 must leave no marks: {once:?}"
);
}
#[test]
fn a_fold_that_creates_a_repeated_mark_is_still_a_fixed_point() {
for (base, mark) in [
('\u{1ef3}', '\u{301}'), ('\u{1ef7}', '\u{301}'), ('\u{010b}', '\u{301}'), ('\u{0101}', '\u{303}'), ('\u{01e7}', '\u{306}'), ] {
let input: String = [base, mark].into_iter().collect();
let once = canonicalize(&input).unwrap().into_owned();
let twice = canonicalize(&once).unwrap().into_owned();
assert_eq!(
once, twice,
"canonicalize is not a fixed point on {input:?}: the fold created a \
repeated mark after the pass that removes them (#835)",
);
let strict = canonicalize_strict(&input).unwrap().into_owned();
assert_eq!(
strict,
canonicalize_strict(&strict).unwrap().into_owned(),
"canonicalize_strict is not a fixed point on {input:?}",
);
let sorted = sort_key(&input, None).unwrap().into_owned();
assert_eq!(
sorted,
sort_key(&sorted, None).unwrap().into_owned(),
"sort_key is not a fixed point on {input:?}",
);
}
}
const MARK_RUN_SPLITTERS: &[&str] = &[
"Step::StripZeroWidth",
"Step::StripInvisible",
"Step::StripControl",
];
#[test]
fn no_pipeline_truncates_further_on_a_second_pass() {
let splitters = [
('\u{200b}', "ZERO WIDTH SPACE"),
('\u{034f}', "COMBINING GRAPHEME JOINER"),
(
'\u{0489}',
"COMBINING CYRILLIC MILLIONS SIGN — cross-script on a Latin base",
),
('\u{200d}', "ZERO WIDTH JOINER"),
(
'\u{0001}',
"START OF HEADING — a C0 control, which #121 names too",
),
];
for (splitter, what) in splitters {
for run in 1..=4 {
let input: String = std::iter::once('a')
.chain(std::iter::repeat_n('\u{0308}', run))
.chain(std::iter::once(splitter))
.chain(std::iter::once('\u{0308}'))
.collect();
for (name, once) in [
(
"canonicalize",
canonicalize(&input).map(std::borrow::Cow::into_owned),
),
(
"canonicalize_strict",
canonicalize_strict(&input).map(std::borrow::Cow::into_owned),
),
(
"sort_key",
sort_key(&input, None).map(std::borrow::Cow::into_owned),
),
(
"search_key",
search_key(&input, None).map(std::borrow::Cow::into_owned),
),
] {
let once = once.expect("pipeline should not error on this input");
let twice = match name {
"canonicalize" => canonicalize(&once).unwrap().into_owned(),
"canonicalize_strict" => canonicalize_strict(&once).unwrap().into_owned(),
"sort_key" => sort_key(&once, None).unwrap().into_owned(),
_ => search_key(&once, None).unwrap().into_owned(),
};
assert_eq!(
once, twice,
"{name} is not idempotent on {run} marks split by {what}: \
{input:?} -> {once:?} -> {twice:?}",
);
}
}
}
}
fn next_list(src: &str) -> Option<(usize, bool)> {
let macro_at = src.find("\n static_steps! {");
let plain_at = src
.match_indices("\n const STEPS: &[Step")
.map(|(i, _)| i)
.find(|&i| {
src[i..]
.lines()
.nth(1)
.is_some_and(|l| l.trim_end().ends_with('['))
});
match (macro_at, plain_at) {
(Some(m), Some(p)) => Some(if m < p { (m, true) } else { (p, false) }),
(Some(m), None) => Some((m, true)),
(None, Some(p)) => Some((p, false)),
(None, None) => None,
}
}
fn step_arrays(src: &str) -> Vec<(&str, &str)> {
let mut out = Vec::new();
let mut rest = src;
while let Some((i, is_macro)) = next_list(rest) {
let i = if is_macro {
if let Some(n) = rest[i..].find("\n [\n") {
i + n
} else {
rest = &rest[i + 20..];
continue;
}
} else {
let decl_end = rest[i + 1..].find('\n').map_or(rest.len(), |n| i + 1 + n);
if !rest[i..decl_end].trim_end().ends_with('[') {
rest = &rest[decl_end..];
continue;
}
i
};
let name = rest[..i]
.lines()
.rev()
.find_map(|line| {
let sig = line.strip_prefix("pub(crate) fn ").or_else(|| {
line.strip_prefix("pub fn ")
.or_else(|| line.strip_prefix("fn "))
})?;
Some(&sig[..sig.find(['<', '(']).unwrap_or(sig.len())])
})
.unwrap_or("<unknown fn>");
let body = &rest[i..];
let end = if is_macro {
body.find("\n ]\n").unwrap_or(body.len())
} else {
body.find("\n ];").unwrap_or(body.len())
};
out.push((name, &body[..end]));
rest = &body[end..];
}
out
}
#[test]
fn step_arrays_names_the_enclosing_function() {
let names: Vec<&str> = step_arrays(include_str!("presets.rs"))
.into_iter()
.map(|(name, _)| name)
.collect();
for expected in [
"canonicalize",
"canonicalize_strict",
"sort_key",
"search_key",
"catalog_key",
"strip_obfuscation",
"strip_format",
"ml_normalize",
] {
assert!(
names.contains(&expected),
"{expected} missing from {names:?}"
);
}
assert!(
!names.contains(&"<unknown fn>"),
"a STEPS array was not attributed to a function: {names:?}",
);
}
#[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", true).unwrap();
assert_eq!(result, "cafe resume");
}
#[test]
fn ml_normalize_folds_case_by_default() {
assert_eq!(
ml_normalize("José Martínez", None, "cldr", true).unwrap(),
"jose martinez"
);
}
#[test]
fn ml_normalize_without_fold_case_keeps_capitals_not_accents() {
assert_eq!(
ml_normalize("José Martínez", None, "cldr", false).unwrap(),
"Jose Martinez"
);
}
#[test]
fn ml_normalize_fold_case_changes_only_case() {
for input in [
"José Martínez",
"MÜNCHEN Straße",
"Hi \u{1F600} THERE",
"\u{FB01}LTER", "A\u{200B}B\tC D", "Fullwidth", "café \u{2247} X", "",
] {
let folded = ml_normalize(input, None, "cldr", true).unwrap();
let unfolded = ml_normalize(input, None, "cldr", false).unwrap();
assert_eq!(
crate::api::fold_case(&unfolded),
folded,
"fold_case changed something other than case for {input:?}: \
folded={folded:?} unfolded={unfolded:?}"
);
}
}
#[test]
fn ml_normalize_without_fold_case_still_transliterates() {
assert_eq!(
ml_normalize("MÜNCHEN Straße", Some("de"), "cldr", false).unwrap(),
"MUeNCHEN Strasse"
);
}
#[test]
fn ml_normalize_idempotent_without_fold_case() {
for input in ["José Martínez", "MÜNCHEN", "Hi \u{1F600}", "a\u{200B}b c"] {
let once = ml_normalize(input, None, "cldr", false).unwrap();
let twice = ml_normalize(&once, None, "cldr", false).unwrap();
assert_eq!(once, twice, "not idempotent for {input:?}");
}
}
#[test]
fn ml_normalize_validates_arguments_in_both_modes() {
for fold in [true, false] {
assert!(ml_normalize("x", None, "bogus", fold).is_err());
assert!(ml_normalize("x", Some("zzz"), "cldr", fold).is_err());
}
}
#[test]
fn no_fold_step_list_is_the_folded_list_minus_fold_case() {
const FULL: &[Step; 9] = &[
Step::Nfkc,
Step::Demojize {
only_if_cldr: true,
policy: crate::emoji::NamePolicy {
skip_tr39_claimed: false,
skip_non_emoji: true,
},
},
Step::Transliterate {
mode: crate::ErrorMode::Ignore,
only_if_lang: true,
},
Step::StripAccents,
Step::Demojize {
only_if_cldr: true,
policy: crate::emoji::NamePolicy {
skip_tr39_claimed: false,
skip_non_emoji: true,
},
},
Step::FoldCase,
Step::StripControl,
Step::StripZeroWidth,
Step::CollapseWs,
];
let derived = without_fold_case(FULL);
let expected: Vec<_> = FULL
.iter()
.filter(|s| !matches!(s, Step::FoldCase))
.map(std::mem::discriminant)
.collect();
let got: Vec<_> = derived.iter().map(std::mem::discriminant).collect();
assert_eq!(got, expected);
}
#[test]
fn test_ml_normalize_ligature() {
let result = ml_normalize("\u{FB01}lter", None, "cldr", true).unwrap();
assert_eq!(result, "filter");
}
#[test]
fn test_ml_normalize_negated_relations_are_preserved() {
for (input, base) in [
("\u{2204}", "\u{2203}"), ("\u{220C}", "\u{220B}"), ("\u{2224}", "\u{2223}"), ("\u{2226}", "\u{2225}"), ("\u{2241}", "\u{223C}"), ("\u{2244}", "\u{2243}"), ("\u{2247}", "\u{2245}"), ("\u{2249}", "\u{2248}"), ("\u{2262}", "\u{2261}"), ("\u{2270}", "\u{2264}"), ("\u{2271}", "\u{2265}"), ("\u{2275}", "\u{2273}"), ("\u{2280}", "\u{227A}"), ("\u{2284}", "\u{2282}"), ("\u{2285}", "\u{2283}"), ("\u{2288}", "\u{2286}"), ("\u{2289}", "\u{2287}"), ] {
let once = ml_normalize(input, None, "cldr", true).unwrap();
assert_eq!(
once, input,
"ml_normalize({input:?}) must not resolve a negated relation to anything"
);
assert_eq!(
once,
ml_normalize(&once, None, "cldr", true).unwrap(),
"ml_normalize not idempotent on {input:?}"
);
let base_out = ml_normalize(base, None, "cldr", true).unwrap();
assert_eq!(
base_out, base,
"ml_normalize({base:?}) should pass a non-emoji math symbol through"
);
assert_ne!(
once, base_out,
"the negated form must not share the positive form's output"
);
}
}
#[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}", "\u{2204}"), ("\u{2224}", "\u{2224}"), ("\u{2226}", "\u{2226}"), ("\u{2241}", "\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
})
}
#[test]
#[ignore = "exhaustive: preset idempotency over code points + base×mark; Tier 3"]
fn exhaustive_preset_idempotency() {
fn idem<F: Fn(&str) -> String>(label: &str, f: F, s: &str) {
let once = f(s);
assert_eq!(once, f(&once), "{label} not idempotent on {s:?}");
}
let cat = |s: &str| catalog_key(s, None, false).unwrap().into_owned();
let ml = |s: &str| ml_normalize(s, None, "cldr", true).unwrap().into_owned();
for cp in 0u32..=0x0010_FFFF {
let Some(c) = char::from_u32(cp) else {
continue;
};
let s = c.to_string();
idem(
"canonicalize",
|x| canonicalize(x).unwrap().into_owned(),
&s,
);
idem("sort_key", |x| sort_key(x, None).unwrap().into_owned(), &s);
idem(
"search_key",
|x| search_key(x, None).unwrap().into_owned(),
&s,
);
idem("catalog_key", cat, &s);
idem("ml_normalize", ml, &s);
}
let marks: Vec<char> = (0x0300u32..=0x036F).filter_map(char::from_u32).collect();
for base in (0u32..=0xFFFF).filter_map(char::from_u32) {
for &m in &marks {
let s: String = [base, m].iter().collect();
idem("catalog_key", cat, &s);
idem("ml_normalize", ml, &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_both_styles(
s in adversarial(),
lang in prop::option::of(prop::sample::select(vec!["de", "ru", "ja"])),
style in prop::sample::select(vec!["cldr", "none"]),
) {
let once = ml_normalize(&s, lang, style, true).unwrap();
let twice = ml_normalize(&once, lang, style, true).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, true).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));
}
}
}
}