use std::collections::{HashMap, HashSet};
use fancy_regex::Regex;
use once_cell::sync::Lazy;
use crate::{
constants::{
AFFIX_SPACING_PATTERNS, DIACRITICS_PATTERN, EXTRA_SPACE_PATTERNS,
MORE_THAN_TWO_REPEAT_PATTERN, NUMBERS_DST, NUMBERS_SRC, PERSIAN_STYLE_PATTERNS,
PUNCTUATION_SPACING_PATTERNS, REPEATED_CHARS_PATTERN, SEPERATE_MI_PATTERN,
SPECIAL_CHARS_PATTERN, SUFFIXES, TRANSLATION_DST, TRANSLATION_SRC, UNICODE_REPLACEMENTS,
},
translate::{make_trans, translate},
word_tokenizer::{WordTokenizer, WordTokenizerConfig},
};
static CHAR_TRANS: Lazy<HashMap<char, char>> =
Lazy::new(|| make_trans(TRANSLATION_SRC, TRANSLATION_DST));
static NUMBER_TRANS: Lazy<HashMap<char, char>> =
Lazy::new(|| make_trans(NUMBERS_SRC, NUMBERS_DST));
fn compile_patterns(pairs: &[(&str, &str)]) -> Vec<(Regex, String)> {
pairs
.iter()
.map(|(pat, repl)| {
let re = Regex::new(pat)
.unwrap_or_else(|e| panic!("failed to compile normalizer regex '{pat}': {e}"));
(re, repl.to_string())
})
.collect()
}
fn apply_patterns(patterns: &[(Regex, String)], mut text: String) -> String {
for (re, repl) in patterns {
text = re.replace_all(&text, repl.as_str()).into_owned();
}
text
}
static EXTRA_SPACE_RE: Lazy<Vec<(Regex, String)>> =
Lazy::new(|| compile_patterns(EXTRA_SPACE_PATTERNS));
static PUNCT_SPACING_RE: Lazy<Vec<(Regex, String)>> =
Lazy::new(|| compile_patterns(PUNCTUATION_SPACING_PATTERNS));
static AFFIX_SPACING_RE: Lazy<Vec<(Regex, String)>> =
Lazy::new(|| compile_patterns(AFFIX_SPACING_PATTERNS));
static PERSIAN_STYLE_RE: Lazy<Vec<(Regex, String)>> =
Lazy::new(|| compile_patterns(PERSIAN_STYLE_PATTERNS));
static DIACRITICS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(DIACRITICS_PATTERN).expect("DIACRITICS_PATTERN"));
static SPECIAL_CHARS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(SPECIAL_CHARS_PATTERN).expect("SPECIAL_CHARS_PATTERN"));
static REPEATED_CHARS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(REPEATED_CHARS_PATTERN).expect("REPEATED_CHARS_PATTERN"));
static MORE_THAN_TWO_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(MORE_THAN_TWO_REPEAT_PATTERN).expect("MORE_THAN_TWO_REPEAT_PATTERN"));
static SEPERATE_MI_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(SEPERATE_MI_PATTERN).expect("SEPERATE_MI_PATTERN"));
#[derive(Debug, Clone)]
pub struct NormalizerConfig {
pub correct_spacing: bool,
pub remove_diacritics: bool,
pub remove_specials_chars: bool,
pub decrease_repeated_chars: bool,
pub persian_style: bool,
pub persian_numbers: bool,
pub unicodes_replacement: bool,
pub seperate_mi: bool,
}
impl Default for NormalizerConfig {
fn default() -> Self {
Self {
correct_spacing: true,
remove_diacritics: true,
remove_specials_chars: true,
decrease_repeated_chars: true,
persian_style: true,
persian_numbers: true,
unicodes_replacement: true,
seperate_mi: true,
}
}
}
pub struct Normalizer {
config: NormalizerConfig,
words: Option<HashMap<String, crate::types::WordEntry>>,
verbs: Option<HashSet<String>>,
suffixes: HashSet<&'static str>,
}
impl Normalizer {
pub fn new() -> Self {
Self::with_config(NormalizerConfig::default())
}
pub fn with_config(config: NormalizerConfig) -> Self {
let words = if config.correct_spacing || config.decrease_repeated_chars {
let tok = WordTokenizer::with_config(WordTokenizerConfig {
join_verb_parts: false,
..Default::default()
});
Some(tok.words)
} else {
None
};
let verbs = if config.seperate_mi {
use crate::lemmatizer::Lemmatizer;
let lem = Lemmatizer::with_joined_parts(false);
Some(lem.verbs.keys().cloned().collect())
} else {
None
};
let suffixes = SUFFIXES.iter().copied().collect();
Self {
config,
words,
verbs,
suffixes,
}
}
pub fn normalize(&self, text: &str) -> String {
let mut text = translate(text, &CHAR_TRANS);
if self.config.persian_style {
text = self.persian_style(&text);
}
if self.config.persian_numbers {
text = self.persian_number(&text);
}
if self.config.remove_diacritics {
text = self.remove_diacritics(&text);
}
if self.config.correct_spacing {
text = self.correct_spacing(&text);
}
if self.config.unicodes_replacement {
text = self.unicodes_replacement(&text);
}
if self.config.remove_specials_chars {
text = self.remove_specials_chars(&text);
}
if self.config.decrease_repeated_chars {
text = self.decrease_repeated_chars(&text);
}
if self.config.seperate_mi {
text = self.seperate_mi(&text);
}
text
}
pub fn correct_spacing(&self, text: &str) -> String {
let text = apply_patterns(&EXTRA_SPACE_RE, text.to_string());
let tok = WordTokenizer::with_config(WordTokenizerConfig {
join_verb_parts: false,
..Default::default()
});
let lines: Vec<String> = text
.split('\n')
.map(|line| {
if line.trim().is_empty() {
return line.to_string();
}
let tokens = tok.tokenize(line);
let spaced = self.token_spacing(tokens);
spaced.join(" ")
})
.collect();
let text = lines.join("\n");
let text = apply_patterns(&AFFIX_SPACING_RE, text);
apply_patterns(&PUNCT_SPACING_RE, text)
}
pub fn remove_diacritics(&self, text: &str) -> String {
DIACRITICS_RE.replace_all(text, "").into_owned()
}
pub fn remove_specials_chars(&self, text: &str) -> String {
SPECIAL_CHARS_RE.replace_all(text, "").into_owned()
}
pub fn decrease_repeated_chars(&self, text: &str) -> String {
let matches: Vec<_> = REPEATED_CHARS_RE
.find_iter(text)
.filter_map(|m| m.ok())
.collect();
if matches.is_empty() {
return text.to_string();
}
let mut result = text.to_string();
for m in matches.into_iter().rev() {
let word = m.as_str().to_string();
if let Some(words) = &self.words {
if !words.contains_key(&word) {
let no_repeat = MORE_THAN_TWO_RE.replace_all(&word, "$1").into_owned();
let two_repeat = MORE_THAN_TWO_RE.replace_all(&word, "$1$1").into_owned();
let no_in = words.contains_key(&no_repeat);
let two_in = words.contains_key(&two_repeat);
let replacement = if no_in != two_in {
if no_in { no_repeat } else { two_repeat }
} else {
two_repeat
};
result.replace_range(m.start()..m.end(), &replacement);
}
}
}
result
}
pub fn persian_number(&self, text: &str) -> String {
translate(text, &NUMBER_TRANS)
}
pub fn persian_style(&self, text: &str) -> String {
apply_patterns(&PERSIAN_STYLE_RE, text.to_string())
}
pub fn unicodes_replacement(&self, text: &str) -> String {
let mut text = text.to_string();
for (old, new) in UNICODE_REPLACEMENTS {
text = text.replace(old, new);
}
text
}
pub fn seperate_mi(&self, text: &str) -> String {
let verbs = match &self.verbs {
Some(v) => v,
None => return text.to_string(),
};
SEPERATE_MI_RE
.replace_all(text, |caps: &fancy_regex::Captures| {
let m = caps.get(0).map_or("", |m| m.as_str());
let candidate = if let Some(stripped) = m.strip_prefix("نمی") {
format!("نمی\u{200C}{stripped}") } else {
format!("می\u{200C}{}", &m[4..]) };
if verbs.contains(&candidate) {
candidate
} else {
m.to_string()
}
})
.into_owned()
}
pub fn token_spacing(&self, tokens: Vec<String>) -> Vec<String> {
let words = match &self.words {
Some(w) => w,
None => return tokens,
};
let verbs = self.verbs.as_ref();
let mut result: Vec<String> = Vec::with_capacity(tokens.len());
for (t, token) in tokens.iter().enumerate() {
let mut joined = false;
if let Some(last) = result.last() {
let pair = format!("{last}\u{200C}{token}");
let pair_is_known = verbs.is_some_and(|vs| vs.contains(&pair))
|| words
.get(&pair)
.is_some_and(|e| e.frequency > 0);
if pair_is_known {
joined = true;
if let Some(next) = tokens.get(t + 1) {
if verbs.is_some_and(|vs| vs.contains(&format!("{token}_{next}"))) {
joined = false;
}
}
} else if self.suffixes.contains(token.as_str()) && words.contains_key(last.as_str()) {
joined = true;
}
if joined {
*result.last_mut().expect("non-empty") = pair;
continue;
}
}
result.push(token.clone());
}
result
}
}
impl Default for Normalizer {
fn default() -> Self {
Self::new()
}
}