use regexr::RegexBuilder;
use std::borrow::Cow;
use unicode_general_category::{get_general_category, GeneralCategory};
use unicode_normalization::{
is_nfc_quick, is_nfd_quick, is_nfkc_quick, is_nfkd_quick, IsNormalized, UnicodeNormalization,
};
use super::precompiled::Precompiled;
pub(crate) fn lowercase(s: &str) -> String {
s.chars().flat_map(char::to_lowercase).collect()
}
pub(crate) fn needs_lowercasing(s: &str) -> bool {
s.chars()
.any(|c| c.is_uppercase() || get_general_category(c) == GeneralCategory::TitlecaseLetter)
}
#[non_exhaustive]
pub enum NormOp {
Nfc,
Nfd,
Nfkc,
Nfkd,
Lowercase,
StripAccents,
ReplaceStr {
from: String,
to: String,
},
ReplaceRegex {
re: Box<regexr::Regex>,
to: String,
},
Prepend(String),
Strip {
left: bool,
right: bool,
},
Nmt,
Precompiled(Precompiled),
}
impl NormOp {
fn apply<'a>(&self, s: Cow<'a, str>) -> Cow<'a, str> {
match self {
NormOp::Nfc => match is_nfc_quick(s.chars()) {
IsNormalized::Yes => s,
_ => Cow::Owned(s.nfc().collect()),
},
NormOp::Nfd => match is_nfd_quick(s.chars()) {
IsNormalized::Yes => s,
_ => Cow::Owned(s.nfd().collect()),
},
NormOp::Nfkc => match is_nfkc_quick(s.chars()) {
IsNormalized::Yes => s,
_ => Cow::Owned(s.nfkc().collect()),
},
NormOp::Nfkd => match is_nfkd_quick(s.chars()) {
IsNormalized::Yes => s,
_ => Cow::Owned(s.nfkd().collect()),
},
NormOp::Lowercase => match needs_lowercasing(&s) {
true => Cow::Owned(lowercase(&s)),
false => s,
},
NormOp::StripAccents => {
let has_mark = s
.chars()
.any(|c| get_general_category(c) == GeneralCategory::NonspacingMark);
match has_mark {
true => Cow::Owned(
s.chars()
.filter(|c| get_general_category(*c) != GeneralCategory::NonspacingMark)
.collect(),
),
false => s,
}
}
NormOp::ReplaceStr { from, to } => {
if from.is_empty() || !s.contains(from.as_str()) {
s
} else {
Cow::Owned(s.replace(from.as_str(), to))
}
}
NormOp::ReplaceRegex { re, to } => match re.replace_all(&s, to) {
Cow::Borrowed(_) => s,
Cow::Owned(out) => Cow::Owned(out),
},
NormOp::Prepend(p) => {
let mut out = p.clone();
out.push_str(&s);
Cow::Owned(out)
}
NormOp::Strip { left, right } => {
let mut t = s.as_ref();
if *left {
t = t.trim_start();
}
if *right {
t = t.trim_end();
}
if t.len() == s.len() {
s
} else {
Cow::Owned(t.to_string())
}
}
NormOp::Nmt => {
let out = nmt(&s);
if out == s.as_ref() {
s
} else {
Cow::Owned(out)
}
}
NormOp::Precompiled(pc) => {
let out = pc.normalize(&s);
if out == s.as_ref() {
s
} else {
Cow::Owned(out)
}
}
}
}
pub fn replace_regex(pattern: &str, to: String) -> Option<Self> {
RegexBuilder::new(pattern)
.build()
.ok()
.map(|re| NormOp::ReplaceRegex {
re: Box::new(re),
to,
})
}
}
#[derive(Default)]
pub struct Normalizer {
ops: Vec<NormOp>,
}
impl Normalizer {
pub fn new(ops: Vec<NormOp>) -> Self {
Self { ops }
}
pub fn is_empty(&self) -> bool {
self.ops.is_empty()
}
pub fn normalize<'a>(&self, text: &'a str) -> Cow<'a, str> {
let mut s = Cow::Borrowed(text);
for op in &self.ops {
s = op.apply(s);
}
s
}
}
fn nmt(s: &str) -> String {
s.chars()
.filter_map(|c| match c as u32 {
0x0001..=0x0008 | 0x000B | 0x000E..=0x001F | 0x007F | 0x008F | 0x009F => None,
0x0009
| 0x000A
| 0x000C
| 0x000D
| 0x1680
| 0x200B..=0x200F
| 0x2028
| 0x2029
| 0x205F
| 0x3000 => Some(' '),
_ => Some(c),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lowercase_does_not_apply_the_greek_final_sigma_rule() {
assert_eq!(lowercase("ΟΠΩΣ"), "οπωσ");
assert_eq!("ΟΠΩΣ".to_lowercase(), "οπως");
assert_eq!(
NormOp::Lowercase.apply(std::borrow::Cow::Borrowed("ΟΠΩΣ")),
"οπωσ"
);
}
#[test]
fn lowercase_fast_path_sees_titlecase() {
assert!(needs_lowercasing("Dž"));
assert!(!needs_lowercasing("already lower"));
assert_eq!(
NormOp::Lowercase.apply(std::borrow::Cow::Borrowed("Dž")),
"dž"
);
}
#[test]
fn nfc_and_nfd_roundtrip() {
let decomposed = "e\u{0301}";
assert_eq!(
NormOp::Nfc.apply(std::borrow::Cow::Owned(decomposed.to_string())),
"é"
);
assert_eq!(
NormOp::Nfd.apply(std::borrow::Cow::Owned("é".to_string())),
decomposed
);
}
#[test]
fn nfkc_folds_compatibility_chars() {
assert_eq!(
NormOp::Nfkc.apply(std::borrow::Cow::Owned("\u{FB01}".to_string())),
"fi"
);
}
#[test]
fn lowercase_lowercases() {
assert_eq!(
NormOp::Lowercase.apply(std::borrow::Cow::Owned("HeLLo".to_string())),
"hello"
);
}
#[test]
fn strip_accents_drops_nonspacing_marks() {
assert_eq!(
NormOp::StripAccents.apply(std::borrow::Cow::Owned("a\u{0304}".to_string())),
"a"
);
}
#[test]
fn strip_accents_preserves_spacing_marks() {
let s = "\u{0915}\u{0940}";
assert_eq!(
NormOp::StripAccents.apply(std::borrow::Cow::Owned(s.to_string())),
s
);
}
#[test]
fn replace_str_is_literal_and_handles_empty() {
let op = NormOp::ReplaceStr {
from: " ".to_string(),
to: "_".to_string(),
};
assert_eq!(
op.apply(std::borrow::Cow::Owned("a b c".to_string())),
"a_b_c"
);
let empty = NormOp::ReplaceStr {
from: String::new(),
to: "x".to_string(),
};
assert_eq!(empty.apply(std::borrow::Cow::Owned("ab".to_string())), "ab");
}
#[test]
fn replace_regex_compiles_and_applies() {
let op = NormOp::replace_regex(r"\s+", "_".to_string()).expect("regex builds");
assert_eq!(
op.apply(std::borrow::Cow::Owned("a b".to_string())),
"a_b"
);
}
#[test]
fn prepend_prefixes() {
assert_eq!(
NormOp::Prepend("▁".to_string()).apply(std::borrow::Cow::Owned("hi".to_string())),
"▁hi"
);
}
#[test]
fn strip_respects_sides() {
let both = NormOp::Strip {
left: true,
right: true,
};
assert_eq!(
both.apply(std::borrow::Cow::Owned(" hi ".to_string())),
"hi"
);
let left_only = NormOp::Strip {
left: true,
right: false,
};
assert_eq!(
left_only.apply(std::borrow::Cow::Owned(" hi ".to_string())),
"hi "
);
}
#[test]
fn nmt_removes_controls_and_maps_whitespace() {
assert_eq!(nmt("a\u{0008}b\tc"), "ab c");
}
#[test]
fn pipeline_applies_in_order() {
let norm = Normalizer::new(vec![NormOp::Nfkd, NormOp::StripAccents, NormOp::Lowercase]);
assert_eq!(norm.normalize("ÀÉÎ"), "aei");
}
#[test]
fn empty_normalizer_is_identity() {
let norm = Normalizer::default();
assert!(norm.is_empty());
assert_eq!(norm.normalize("unchanged"), "unchanged");
}
}