use regex::Regex;
use std::sync::OnceLock;
use unicode_normalization::UnicodeNormalization;
const INVISIBLE: &[char] = &[
'\u{200B}', '\u{200C}', '\u{200D}', '\u{2060}', '\u{FEFF}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', ];
pub fn strip_invisible(s: &str) -> String {
if !s.chars().any(|c| INVISIBLE.contains(&c)) {
return s.to_string();
}
s.chars().filter(|c| !INVISIBLE.contains(c)).collect()
}
fn mn_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\p{Mn}").unwrap())
}
pub(crate) fn detection_shadow(line: &str) -> String {
if line.is_ascii() {
return line.to_ascii_lowercase();
}
let visible = strip_invisible(line);
let decomposed: String = visible.nfkd().collect();
let no_marks = mn_regex().replace_all(&decomposed, "");
let lowered = no_marks.to_lowercase();
let lowered_decomposed: String = lowered.nfkd().collect();
let no_lower_marks = mn_regex().replace_all(&lowered_decomposed, "");
let nfkc: String = no_lower_marks.nfkc().collect();
let lowered = nfkc.to_lowercase();
fold_confusables(&lowered)
}
fn fold_confusables(s: &str) -> String {
s.chars().map(fold_char).collect()
}
fn fold_char(c: char) -> char {
match c {
'\u{0430}' => 'a',
'\u{0435}' => 'e',
'\u{043E}' => 'o',
'\u{0440}' => 'p',
'\u{0441}' => 'c',
'\u{0445}' => 'x',
'\u{0443}' => 'y',
'\u{0456}' => 'i',
'\u{0458}' => 'j',
'\u{0455}' => 's',
'\u{0501}' => 'd',
'\u{043A}' => 'k',
'\u{03BF}' => 'o',
'\u{03B1}' => 'a',
'\u{03C1}' => 'p',
'\u{03BD}' => 'v',
'\u{03B9}' => 'i',
'\u{03BA}' => 'k',
_ => c,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_zero_width_and_bidi() {
let s = "ig\u{200B}nore\u{202E}\u{FEFF}";
assert_eq!(strip_invisible(s), "ignore");
}
#[test]
fn leaves_ordinary_text_and_whitespace_untouched() {
let s = "café ☕\n\tTürkçe — ok";
assert_eq!(strip_invisible(s), s);
}
#[test]
fn strip_invisible_is_idempotent() {
let s = "a\u{200D}b\u{2069}c";
let once = strip_invisible(s);
assert_eq!(strip_invisible(&once), once);
}
#[test]
fn shadow_folds_fullwidth_and_math_to_ascii() {
assert_eq!(
detection_shadow("\u{FF49}\u{FF47}\u{FF4E}\u{FF4F}\u{FF52}\u{FF45}"),
"ignore"
); assert_eq!(
detection_shadow("\u{1D422}\u{1D420}\u{1D427}\u{1D428}\u{1D42B}\u{1D41E}"),
"ignore"
); }
#[test]
fn shadow_folds_cyrillic_confusables() {
assert_eq!(detection_shadow("\u{0456}gnore"), "ignore");
assert_eq!(detection_shadow("\u{0410}dmin"), "admin");
}
#[test]
fn shadow_strips_combining_marks() {
assert_eq!(detection_shadow("i\u{0301}gnore"), "ignore");
}
#[test]
fn shadow_strips_marks_exposed_by_case_and_decomposition() {
assert_eq!(detection_shadow("\u{0130}GNORE"), "ignore"); assert_eq!(detection_shadow("\u{00ED}gnore"), "ignore"); }
#[test]
fn shadow_lowercases() {
assert_eq!(detection_shadow("IGNORE"), "ignore");
}
#[test]
fn shadow_ascii_fast_path_equals_ascii_lowercase() {
assert_eq!(detection_shadow("Ignore The ABOVE"), "ignore the above");
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_strip_invisible_does_not_panic(s in "[\\s\\S]{0,500}") {
let _ = strip_invisible(&s);
}
#[test]
fn prop_strip_invisible_only_removes_target_set(s in "[\\s\\S]{0,500}") {
let out = strip_invisible(&s);
prop_assert!(out.chars().all(|c| !INVISIBLE.contains(&c)));
prop_assert!(out.chars().all(|c| s.contains(c)));
}
#[test]
fn prop_detection_shadow_does_not_panic(s in "[\\s\\S]{0,500}") {
for line in s.split('\n') {
let _ = detection_shadow(line);
}
}
#[test]
fn prop_detection_shadow_is_idempotent(s in "[a-zA-Z0-9 ]{0,200}") {
let once = detection_shadow(&s);
prop_assert_eq!(detection_shadow(&once), once);
}
#[test]
fn prop_detection_shadow_ascii_is_ascii_lowercase(s in "[ -~]{0,200}") {
prop_assert_eq!(detection_shadow(&s), s.to_ascii_lowercase());
}
}
}