use crate::tables;
fn validate_target_script(target_script: &str) -> Result<(), crate::ErrorRepr> {
match target_script {
"latin" | "cyrillic" => Ok(()),
_ => Err(crate::ErrorRepr::InvalidTargetScript {
got: target_script.to_owned(),
}),
}
}
pub(crate) fn normalize_confusables(
text: &str,
target_script: &str,
) -> Result<String, crate::ErrorRepr> {
const MAX_PASSES: usize = 8;
let mut cur = match normalize_confusables_cow(text, target_script)? {
std::borrow::Cow::Borrowed(s) => return Ok(s.to_owned()),
std::borrow::Cow::Owned(s) => s,
};
for _ in 0..MAX_PASSES {
match normalize_confusables_cow(&cur, target_script)? {
std::borrow::Cow::Borrowed(_) => return Ok(cur),
std::borrow::Cow::Owned(next) if next == cur => return Ok(cur),
std::borrow::Cow::Owned(next) => cur = next,
}
}
debug_assert!(
false,
"normalize_confusables did not converge in {MAX_PASSES} passes: {cur:?}"
);
Ok(cur)
}
pub(crate) fn normalize_confusables_cow<'a>(
text: &'a str,
target_script: &str,
) -> Result<std::borrow::Cow<'a, str>, crate::ErrorRepr> {
use std::borrow::Cow;
validate_target_script(target_script)?;
let map = tables::resolve_confusable_map(target_script);
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 map.and_then(|m| m.get(&ch).copied()) {
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) = map.and_then(|m| m.get(&ch).copied()) {
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 map.and_then(|m| m.get(&ch).copied()) {
Some(replacement) => out.push_str(replacement),
None => out.push(ch),
}
}
return Ok(Cow::Owned(out));
}
}
Ok(Cow::Borrowed(text))
}
pub(crate) fn normalize_confusables_into(
text: &str,
target_script: &str,
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);
for ch in text.chars() {
match map.and_then(|m| m.get(&ch).copied()) {
Some(replacement) => out.push_str(replacement),
None => out.push(ch),
}
}
Ok(())
}
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 map.is_some_and(|m| m.contains_key(&ch)) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
#[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).unwrap();
let twice = normalize_confusables(&once, script).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").unwrap();
assert_eq!(result, "a");
}
#[test]
fn test_normalize_confusables_passthrough() {
let result = normalize_confusables("hello", "latin").unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_normalize_confusables_empty() {
let result = normalize_confusables("", "latin").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").unwrap(),
normalize_confusables(&nfd, "latin").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").unwrap(), "all");
assert_eq!(normalize_confusables("hello", "latin").unwrap(), "hello");
}
#[test]
fn composition_excluded_presentation_form_is_form_invariant() {
assert_eq!(
normalize_confusables("\u{FB2B}", "latin").unwrap(),
"\u{FB2B}"
);
assert_eq!(
normalize_confusables("\u{05E9}\u{05C2}", "latin").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").unwrap();
assert_eq!(result, "hello world");
}
#[test]
fn test_normalize_confusables_nfc_vs_nfd() {
let nfc = "\u{00e9}"; let result = normalize_confusables(nfc, "latin").unwrap();
assert_eq!(result, nfc);
}
#[test]
fn normalize_confusables_idempotent_when_fold_and_compose_interact() {
let once = normalize_confusables("\u{a5}\u{340}", "latin").unwrap();
assert_eq!(once, "\u{1ef2}"); assert_eq!(normalize_confusables(&once, "latin").unwrap(), once);
let once = normalize_confusables("\u{04AA}\u{0327}", "latin").unwrap();
assert_eq!(once, "C");
assert_eq!(normalize_confusables(&once, "latin").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").unwrap();
let twice = normalize_confusables(&once, "latin").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").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").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").unwrap();
let _ = result.len(); }
}
}
}