use crate::tables;
#[inline]
fn skipped_by_detection(ch: char) -> bool {
ch.is_ascii_graphic()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum DigitPolicy {
Numeric,
Tr39,
Preserve,
}
const MAX_CONFUSABLE_PASSES: usize = 8;
fn validate_digit_policy(digit_policy: &str) -> Result<(), crate::ErrorRepr> {
DigitPolicy::from_token(digit_policy).map(|_| ())
}
impl DigitPolicy {
pub(crate) fn as_token(self) -> &'static str {
match self {
Self::Numeric => "numeric",
Self::Tr39 => "tr39",
Self::Preserve => "preserve",
}
}
pub(crate) fn from_token(token: &str) -> Result<Self, crate::ErrorRepr> {
match token {
"numeric" => Ok(Self::Numeric),
"tr39" => Ok(Self::Tr39),
"preserve" => Ok(Self::Preserve),
_ => Err(crate::ErrorRepr::InvalidDigitPolicy {
got: token.to_owned(),
}),
}
}
}
#[inline]
fn lookup_with_policy(
map: Option<&'static phf::Map<char, &'static str>>,
ch: char,
tr39_digits: bool,
preserve_digits: bool,
) -> Option<&'static str> {
if tr39_digits {
if let Some(over) = crate::tables::confusable_digit_tr39_override(ch) {
return Some(over);
}
}
let hit = map.and_then(|m| m.get(&ch).copied())?;
if preserve_digits && hit.len() == 1 && hit.as_bytes()[0].is_ascii_digit() {
return None;
}
Some(hit)
}
fn validate_target_script(target_script: &str) -> Result<(), crate::ErrorRepr> {
match target_script {
"latin" | "cyrillic" | "arabic" | "hebrew" => Ok(()),
_ => Err(crate::ErrorRepr::InvalidTargetScript {
got: target_script.to_owned(),
}),
}
}
pub(crate) fn normalize_confusables(
text: &str,
target_script: &str,
digit_policy: &str,
) -> Result<String, crate::ErrorRepr> {
Ok(normalize_confusables_fixed_cow(text, target_script, digit_policy)?.into_owned())
}
pub(crate) fn normalize_confusables_cow<'a>(
text: &'a str,
target_script: &str,
digit_policy: &str,
) -> Result<std::borrow::Cow<'a, str>, crate::ErrorRepr> {
use std::borrow::Cow;
validate_target_script(target_script)?;
validate_digit_policy(digit_policy)?;
let map = tables::resolve_confusable_map(target_script);
let tr39_digits = digit_policy == "tr39" && target_script == "latin";
let preserve_digits = digit_policy == "preserve";
if !text.is_ascii() && crate::compose::needs_composition(text) {
let mut out = String::with_capacity(text.len());
for (ch, _) in crate::compose::composed(text) {
match lookup_with_policy(map, ch, tr39_digits, preserve_digits) {
Some(replacement) => out.push_str(replacement),
None => out.push(ch),
}
}
return Ok(Cow::Owned(out));
}
for (i, ch) in text.char_indices() {
if let Some(replacement) = lookup_with_policy(map, ch, tr39_digits, preserve_digits) {
let mut out = String::with_capacity(text.len());
out.push_str(&text[..i]);
out.push_str(replacement);
for ch in text[i + ch.len_utf8()..].chars() {
match lookup_with_policy(map, ch, tr39_digits, preserve_digits) {
Some(replacement) => out.push_str(replacement),
None => out.push(ch),
}
}
return Ok(Cow::Owned(out));
}
}
Ok(Cow::Borrowed(text))
}
pub(crate) fn normalize_confusables_fixed_cow<'a>(
text: &'a str,
target_script: &str,
digit_policy: &str,
) -> Result<std::borrow::Cow<'a, str>, crate::ErrorRepr> {
let mut cur = match normalize_confusables_cow(text, target_script, digit_policy)? {
std::borrow::Cow::Borrowed(s) => return Ok(std::borrow::Cow::Borrowed(s)),
std::borrow::Cow::Owned(s) => s,
};
for _ in 0..MAX_CONFUSABLE_PASSES {
match normalize_confusables_cow(&cur, target_script, digit_policy)? {
std::borrow::Cow::Borrowed(_) => return Ok(std::borrow::Cow::Owned(cur)),
std::borrow::Cow::Owned(next) if next == cur => {
return Ok(std::borrow::Cow::Owned(cur));
}
std::borrow::Cow::Owned(next) => cur = next,
}
}
debug_assert!(
false,
"normalize_confusables did not converge in {MAX_CONFUSABLE_PASSES} passes: {cur:?}"
);
Ok(std::borrow::Cow::Owned(cur))
}
pub(crate) fn normalize_confusables_into(
text: &str,
target_script: &str,
digit_policy: DigitPolicy,
out: &mut String,
) -> Result<(), crate::ErrorRepr> {
validate_target_script(target_script)?;
out.clear();
out.reserve(text.len());
let map = tables::resolve_confusable_map(target_script);
let tr39_digits = digit_policy == DigitPolicy::Tr39 && target_script == "latin";
let preserve_digits = digit_policy == DigitPolicy::Preserve;
for ch in text.chars() {
match lookup_with_policy(map, ch, tr39_digits, preserve_digits) {
Some(replacement) => out.push_str(replacement),
None => out.push(ch),
}
}
Ok(())
}
pub(crate) fn unmapped_confusables(target_script: &str) -> Result<Vec<char>, crate::ErrorRepr> {
validate_target_script(target_script)?;
Ok(tables::unmapped_confusable_sources(target_script))
}
pub(crate) fn find_unmapped_confusables(
text: &str,
target_script: &str,
) -> Result<Vec<(char, usize)>, crate::ErrorRepr> {
validate_target_script(target_script)?;
let map = tables::resolve_confusable_map(target_script);
let mut out = Vec::new();
for (ch, offset) in crate::compose::composed(text) {
let mapped = map.is_some_and(|m| m.contains_key(&ch));
if !mapped && tables::is_upstream_confusable_source(ch) {
out.push((ch, offset));
}
}
Ok(out)
}
pub(crate) fn find_confusables(
text: &str,
target_script: &str,
allowed_scripts: &[&str],
) -> Result<Vec<(char, usize, &'static str)>, crate::ErrorRepr> {
validate_target_script(target_script)?;
let allowed = canonical_scripts(allowed_scripts)?;
let map = tables::resolve_confusable_map(target_script);
let mut out = Vec::new();
for (ch, offset) in crate::compose::composed(text) {
if skipped_by_detection(ch) {
continue;
}
if let Some(target) = map.and_then(|m| m.get(&ch)) {
if !allowed.is_empty() && is_allowed(ch, &allowed) {
continue;
}
out.push((ch, offset, *target));
}
}
Ok(out)
}
fn is_allowed(ch: char, allowed: &[&'static str]) -> bool {
let script = crate::scripts::detect_char_script(ch);
if script == "Common" || script == "Inherited" {
return false;
}
allowed.contains(&script)
}
fn canonical_scripts(names: &[&str]) -> Result<Vec<&'static str>, crate::ErrorRepr> {
names
.iter()
.map(|name| {
crate::metadata::SCRIPTS
.iter()
.find(|known| known.eq_ignore_ascii_case(name))
.copied()
.ok_or_else(|| crate::ErrorRepr::UnknownScript {
got: (*name).to_owned(),
})
})
.collect()
}
pub(crate) fn is_confusable(text: &str, target_script: &str) -> Result<bool, crate::ErrorRepr> {
validate_target_script(target_script)?;
let map = tables::resolve_confusable_map(target_script);
for (ch, _) in crate::compose::composed(text) {
if skipped_by_detection(ch) {
continue;
}
if map.is_some_and(|m| m.contains_key(&ch)) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
#[test]
fn printable_ascii_is_not_a_detection() {
for target in crate::api::TargetScript::ALL {
for cp in 0x21u32..0x7Fu32 {
let ch = char::from_u32(cp).expect("ASCII is always a scalar value");
let s = ch.to_string();
assert!(
!is_confusable(&s, target.as_str()).unwrap(),
"U+{cp:04X} {ch:?} is reported as confusable for {target:?}"
);
assert!(
find_confusables(&s, target.as_str(), &[])
.unwrap()
.is_empty(),
"U+{cp:04X} {ch:?} is located as confusable for {target:?}"
);
}
}
}
#[test]
fn the_fold_still_rewrites_what_the_detector_ignores() {
for (source, folded) in [('|', "l"), ('"', "''"), ('`', "'")] {
let s = source.to_string();
assert_eq!(
normalize_confusables(&s, "latin", "numeric").unwrap(),
folded
);
assert!(!is_confusable(&s, "latin").unwrap());
}
}
#[test]
fn a_homoglyph_beside_ascii_punctuation_is_still_detected() {
assert!(is_confusable("say \"p\u{0430}ypal\"", "latin").unwrap());
let hits = find_confusables("say \"p\u{0430}ypal\"", "latin", &[]).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, '\u{0430}');
}
#[test]
fn every_target_script_variant_validates() {
for variant in crate::api::TargetScript::ALL {
assert!(
validate_target_script(variant.as_str()).is_ok(),
"TargetScript::{variant:?} ({:?}) is not accepted by validate_target_script",
variant.as_str(),
);
}
}
#[test]
fn the_error_message_names_exactly_the_accepted_scripts() {
let message = crate::ErrorRepr::InvalidTargetScript {
got: "klingon".to_owned(),
}
.to_string();
for variant in crate::api::TargetScript::ALL {
let quoted = format!("'{}'", variant.as_str());
assert!(
message.contains("ed),
"the error does not name the accepted script {quoted}: {message:?}",
);
}
for absent in ["'greek'", "'han'", "'hangul'"] {
assert!(
!message.contains(absent),
"the error names {absent}, which is not accepted: {message:?}",
);
}
assert!(
message.contains("got 'klingon'"),
"the error must still report the offending value: {message:?}",
);
}
#[test]
fn the_validator_accepts_nothing_outside_the_enum() {
let known: Vec<&str> = crate::api::TargetScript::ALL
.iter()
.map(|v| v.as_str())
.collect();
for candidate in [
"greek", "klingon", "Latin", "LATIN", "", "arabic ", "hebrew\n", "han",
] {
if known.contains(&candidate) {
continue;
}
assert!(
validate_target_script(candidate).is_err(),
"validate_target_script accepted {candidate:?}, which TargetScript cannot \
express",
);
}
}
#[test]
#[ignore = "exhaustive: ~9M (confusable × mark) pairs; run in Tier 3 / pre-release"]
fn exhaustive_fold_compose_idempotent_and_complete() {
use unicode_normalization::char::is_combining_mark;
let marks: Vec<char> = (0u32..=0x0010_FFFF)
.filter_map(char::from_u32)
.filter(|&c| is_combining_mark(c))
.collect();
for script in ["latin", "cyrillic"] {
let map = tables::resolve_confusable_map(script).unwrap();
for &base in map.keys() {
for &m in &marks {
let s: String = [base, m].iter().collect();
let once = normalize_confusables(&s, script, "numeric").unwrap();
let twice = normalize_confusables(&once, script, "numeric").unwrap();
assert_eq!(
once, twice,
"not idempotent: base U+{:04X} + mark U+{:04X} ({script})",
base as u32, m as u32
);
assert!(
!is_confusable(&once, script).unwrap(),
"residual confusable after normalize: base U+{:04X} + mark U+{:04X} ({script}) → {once:?}",
base as u32, m as u32
);
}
}
}
}
use super::*;
#[test]
fn test_normalize_confusables_cyrillic() {
let result = normalize_confusables("\u{0430}", "latin", "numeric").unwrap();
assert_eq!(result, "a");
}
#[test]
fn test_normalize_confusables_passthrough() {
let result = normalize_confusables("hello", "latin", "numeric").unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_normalize_confusables_empty() {
let result = normalize_confusables("", "latin", "numeric").unwrap();
assert_eq!(result, "");
}
#[test]
fn test_is_confusable_true() {
assert!(is_confusable("\u{0430}", "latin").unwrap());
}
#[test]
fn test_is_confusable_false() {
assert!(!is_confusable("hello", "latin").unwrap());
}
#[test]
fn test_is_confusable_empty() {
assert!(!is_confusable("", "latin").unwrap());
}
#[test]
fn fold_and_detect_are_form_invariant() {
use unicode_normalization::UnicodeNormalization;
for ch in ['\u{0457}', '\u{00E7}', '\u{03AF}', '\u{0625}'] {
let nfc: String = std::iter::once(ch).collect();
let nfd: String = std::iter::once(ch).nfd().collect();
assert_ne!(nfc, nfd, "{ch:?} must actually decompose for this test");
assert_eq!(
normalize_confusables(&nfc, "latin", "numeric").unwrap(),
normalize_confusables(&nfd, "latin", "numeric").unwrap(),
"fold not form-invariant on {ch:?}"
);
assert_eq!(
is_confusable(&nfc, "latin").unwrap(),
is_confusable(&nfd, "latin").unwrap(),
"detection not form-invariant on {ch:?}"
);
}
}
#[test]
fn nfc_form_preserves_existing_output() {
assert_eq!(
normalize_confusables("\u{0430}ll", "latin", "numeric").unwrap(),
"all"
);
assert_eq!(
normalize_confusables("hello", "latin", "numeric").unwrap(),
"hello"
);
}
#[test]
fn composition_excluded_presentation_form_is_form_invariant() {
assert_eq!(
normalize_confusables("\u{FB2B}", "latin", "numeric").unwrap(),
"\u{FB2B}"
);
assert_eq!(
normalize_confusables("\u{05E9}\u{05C2}", "latin", "numeric").unwrap(),
"\u{FB2B}"
);
}
#[test]
fn test_validate_target_script_latin_ok() {
assert!(validate_target_script("latin").is_ok());
}
#[test]
fn test_validate_target_script_cyrillic_ok() {
assert!(validate_target_script("cyrillic").is_ok());
}
#[test]
fn test_validate_target_script_invalid() {
assert!(validate_target_script("greek").is_err());
assert!(validate_target_script("").is_err());
assert!(validate_target_script("Latin").is_err()); assert!(validate_target_script("Cyrillic").is_err()); }
#[test]
fn test_normalize_confusables_mixed_long() {
let input = "h\u{0435}ll\u{043E} w\u{043E}rld"; let result = normalize_confusables(input, "latin", "numeric").unwrap();
assert_eq!(result, "hello world");
}
#[test]
fn test_normalize_confusables_nfc_vs_nfd() {
let nfc = "\u{00e9}"; let result = normalize_confusables(nfc, "latin", "numeric").unwrap();
assert_eq!(result, nfc);
}
#[test]
fn normalize_confusables_idempotent_when_fold_and_compose_interact() {
let once = normalize_confusables("\u{a5}\u{340}", "latin", "numeric").unwrap();
assert_eq!(once, "\u{1ef2}"); assert_eq!(
normalize_confusables(&once, "latin", "numeric").unwrap(),
once
);
let once = normalize_confusables("\u{04AA}\u{0327}", "latin", "numeric").unwrap();
assert_eq!(once, "C");
assert_eq!(
normalize_confusables(&once, "latin", "numeric").unwrap(),
once
);
assert!(!is_confusable(&once, "latin").unwrap());
}
#[test]
fn confusable_table_values_are_non_empty() {
for script in ["latin", "cyrillic"] {
let map = tables::resolve_confusable_map(script).unwrap();
for (&key, &value) in map.entries() {
assert!(
!value.is_empty(),
"empty confusable mapping for U+{:04X} ({script})",
key as u32
);
}
}
}
mod proptest_properties {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn normalize_confusables_idempotent(s in "\\PC*") {
let once = normalize_confusables(&s, "latin", "numeric").unwrap();
let twice = normalize_confusables(&once, "latin", "numeric").unwrap();
prop_assert_eq!(&once, &twice,
"normalize_confusables is not idempotent on: {:?}", s);
}
#[test]
fn normalized_is_not_confusable(s in "\\PC*") {
let normalized = normalize_confusables(&s, "latin", "numeric").unwrap();
let still_confusable = is_confusable(&normalized, "latin").unwrap();
prop_assert!(!still_confusable,
"is_confusable returned true after normalize_confusables on: {:?} → {:?}",
s, normalized);
}
#[test]
fn fold_never_annihilates_content(s in "\\PC+") {
let result = normalize_confusables(&s, "latin", "numeric").unwrap();
prop_assert!(!result.is_empty(),
"non-empty input {:?} normalized to empty", s);
}
#[test]
fn normalize_confusables_valid_utf8(s in "\\PC*") {
let result = normalize_confusables(&s, "latin", "numeric").unwrap();
let _ = result.len(); }
}
}
}
pub(crate) fn prototype_fold_into(input: &str, digits: DigitPolicy, out: &mut String) -> bool {
let fold_digits = matches!(digits, DigitPolicy::Tr39);
let hit = input
.bytes()
.any(|b| b == b'I' || (fold_digits && (b == b'1' || b == b'0')));
if !hit {
return false;
}
out.clear();
out.reserve(input.len());
for c in input.chars() {
out.push(match c {
'I' => 'l',
'1' if fold_digits => 'l',
'0' if fold_digits => 'O',
other => other,
});
}
true
}
#[cfg(test)]
mod prototype_fold_tests {
use super::*;
fn fold(s: &str, d: DigitPolicy) -> String {
let mut out = String::new();
if prototype_fold_into(s, d, &mut out) {
out
} else {
s.to_owned()
}
}
#[test]
fn the_two_halves_are_separately_gated() {
for policy in [DigitPolicy::Numeric, DigitPolicy::Preserve] {
assert_eq!(
fold("paypaI", policy),
"paypal",
"letter half is unconditional"
);
assert_eq!(fold("SKU-1O0", policy), "SKU-1O0", "digits untouched");
}
assert_eq!(fold("paypaI", DigitPolicy::Tr39), "paypal");
assert_eq!(fold("SKU-1O0", DigitPolicy::Tr39), "SKU-lOO");
}
#[test]
fn it_reports_whether_it_changed_anything() {
let mut out = String::new();
assert!(!prototype_fold_into("paypal", DigitPolicy::Tr39, &mut out));
assert!(!prototype_fold_into(
"no digits here",
DigitPolicy::Numeric,
&mut out
));
assert!(!prototype_fold_into(
"SKU-100",
DigitPolicy::Numeric,
&mut out
));
assert!(prototype_fold_into("SKU-100", DigitPolicy::Tr39, &mut out));
assert!(prototype_fold_into(
"paypaI",
DigitPolicy::Numeric,
&mut out
));
}
#[test]
fn lowercase_o_is_left_alone() {
assert_eq!(fold("book", DigitPolicy::Tr39), "book");
assert_eq!(fold("BOOK", DigitPolicy::Tr39), "BOOK");
}
#[test]
fn non_ascii_is_not_this_steps_business() {
assert_eq!(fold("Ω→café", DigitPolicy::Tr39), "Ω→café");
}
}