Skip to main content

rust_censure/
censor.rs

1use once_cell::sync::Lazy;
2use std::collections::HashMap;
3use std::sync::{Arc};
4use parking_lot::{RwLock, RwLockWriteGuard};
5
6use super::structs::*;
7
8use crate::lang::common::{
9    NORMALIZATION_PATTERNS, PAT_PUNCT3
10};
11use crate::lang::LangProvider;
12use crate::util::{remove_duplicates, is_pi_or_e_word};
13use fancy_regex;
14
15impl Censor {
16    pub fn new(lang: CensorLang) -> Result<Self, CensorError> {
17        match lang {
18            CensorLang::Ru => {
19                let lang = Arc::new(crate::lang::ru::Ru {});
20                Censor::from(lang)
21            },
22            CensorLang::En => {
23                let lang = Arc::new(crate::lang::en::En {});
24                Censor::from(lang)
25            },
26        }
27    }
28
29    pub fn from<L>(lang: Arc<L>) -> Result<Self, CensorError>
30    where
31        L: LangProvider + Send + Sync + 'static,
32    {
33        let data = lang.data();
34        Ok(Self {
35            lang,
36            data,
37            re_cache: Lazy::new(|| Arc::new(RwLock::new(HashMap::with_capacity(100))))
38        })
39    }
40
41    fn is_match_cached(&self, pat: &str, text: &str) -> bool {
42        // Check cache
43        {
44            let cache = self.re_cache.read();
45            if let Some(r) = cache.get(pat) {
46                return r.is_match(text).unwrap_or(false)
47            }
48        }
49
50        // Compile and cache
51        let r = fancy_regex::Regex::new(pat)
52            .map_err(|e| CensorError::RegexCompilationFailed(e.to_string())).unwrap();
53        let res = r.is_match(text).unwrap_or(false);
54        {
55            let mut cache = self.re_cache.write();
56            cache.insert(pat.to_string(), r);
57        }
58        res
59    }
60
61    // fn cache_pattern(&self, pat: &str, r: fancy_regex::Regex, cache: &mut std::sync::RwLockWriteGuard<HashMap<String, fancy_regex::Regex>>) {
62    //     // Check cache
63    //     if cache.contains_key(pat) {
64    //         return // already cached
65    //     }
66    //
67    //     cache.insert(pat.to_string(), r);
68    // }
69
70    fn compile_and_cache_pattern(&self, pat: &str, cache: &mut RwLockWriteGuard<HashMap<String, fancy_regex::Regex>>) {
71        let r = fancy_regex::Regex::new(pat)
72            .map_err(|e| CensorError::RegexCompilationFailed(e.to_string())).unwrap();
73        cache.insert(pat.to_string(), r);
74    }
75
76    pub fn precompile_all_patterns(&self) {
77        self.precompile_foul_data();
78        self.precompile_foul_core();
79        self.precompile_bad_phrases();
80        self.precompile_bad_semi_phrases();
81        self.precompile_excludes_core();
82        self.precompile_excludes_data();
83    }
84
85    pub fn precompile_foul_data(&self) {
86        let mut cache = self.re_cache.write();
87
88        for (_, pats) in self.data.foul_data {
89            for &pat in pats {
90                self.compile_and_cache_pattern(pat, &mut cache);
91            }
92        }
93    }
94
95    pub fn precompile_foul_core(&self) {
96        let mut cache = self.re_cache.write();
97
98        for (pat, _) in self.data.foul_core {
99            self.compile_and_cache_pattern(pat, &mut cache);
100        }
101    }
102
103    pub fn precompile_bad_phrases(&self) {
104        let mut cache = self.re_cache.write();
105
106        for &pat in self.data.bad_phrases {
107            self.compile_and_cache_pattern(pat, &mut cache);
108        }
109    }
110
111    pub fn precompile_bad_semi_phrases(&self) {
112        let mut cache = self.re_cache.write();
113
114        for &pat in self.data.bad_semi_phrases {
115            self.compile_and_cache_pattern(pat, &mut cache);
116        }
117    }
118
119    pub fn precompile_excludes_core(&self) {
120        let mut cache = self.re_cache.write();
121
122        for (pat, _) in self.data.excludes_core {
123            self.compile_and_cache_pattern(pat, &mut cache);
124        }
125    }
126
127    pub fn precompile_excludes_data(&self) {
128        let mut cache = self.re_cache.write();
129
130        for (_, pats) in self.data.excludes_data {
131            for &pat in pats {
132                self.compile_and_cache_pattern(pat, &mut cache);
133            }
134        }
135    }
136
137    fn replace_all_cached(&self, pat: &str, text: &str, repl: &str) -> Option<String> {
138        // Quick negative guard: if it doesn't match, skip compiling/allocating a String for replace.
139        if !self.is_match_cached(pat, text) {
140            return None;
141        }
142
143        // read from cache
144        let cache = self.re_cache.read();
145        let compiled = cache.get(pat).unwrap();
146
147        // replace
148        let replaced = compiled.replace_all(text, repl).into_owned();
149        if replaced == text { None } else { Some(replaced) }
150    }
151
152    fn split_line(&self, s: &str) -> Vec<String> {
153        self.lang.split_line(s)
154    }
155
156    fn prepare_word(&self, mut w: String) -> String {
157        if !is_pi_or_e_word(&w) {
158            // trim punctuation edges
159            w = PAT_PUNCT3.replace_all(&w, "").into_owned();
160        }
161        let mut w = w.to_lowercase();
162
163        // apply normalization patterns in order
164        for (pat, rep) in NORMALIZATION_PATTERNS.iter() {
165            w = pat.replace_all(&w, *rep).into_owned();
166        }
167
168        // transliteration of similar chars
169        w = crate::lang::common::translate_similar_chars(&w, self.data.trans_tab);
170
171        // deduplicate (AAA -> AA)
172        remove_duplicates(&w)
173    }
174
175    pub fn is_word_good(&self, raw: &str) -> bool {
176        let w = self.prepare_word(raw.to_string());
177        self.check_word_impl_fast(&w)
178    }
179
180    fn check_word_impl(&self, prepared: &String) -> WordInfo {
181        let mut info = WordInfo::new(Box::from(prepared.as_str()));
182
183        // Build a string from the first character
184        let fl_str = String::from(info.word.chars().next().map(|c| c.to_string()).unwrap_or_default());
185
186        // 1) Accuse stage: FOUL_DATA[first_letter]
187        if let Some(pats) = self.data.foul_data.get(fl_str.as_str()) {
188            for &pat in pats {
189                if self.is_match_cached(pat, &info.word) {
190                    info.is_good = false;
191                    info.accuse.push(Box::from(pat)); // now stored as a string rule
192                    break;
193                }
194            }
195        }
196
197        // 2) If still good → check FOUL_CORE
198        if info.is_good {
199            for (&_key, &pat) in self.data.foul_core.iter() {
200                if self.is_match_cached(pat, prepared) {
201                    info.is_good = false;
202                    info.accuse.push(Box::from(pat));
203                    break;
204                }
205            }
206        }
207
208        // 3) If still good → check BAD_SEMI_PHRASES
209        if info.is_good {
210            for &pat in self.data.bad_semi_phrases.iter() {
211                if self.is_match_cached(pat, prepared) {
212                    info.is_good = false;
213                    info.accuse.push(Box::from(pat));
214                    break;
215                }
216            }
217        }
218
219        // 4) Excuse stage: if already accused, check exceptions
220        if !info.is_good {
221            // EXCLUDES_CORE
222            for (&_key, &pat) in self.data.excludes_core.iter() {
223                if self.is_match_cached(pat, prepared) {
224                    info.is_good = true;
225                    info.excuse.push(Box::from(pat));
226                    break;
227                }
228            }
229            // EXCLUDES_DATA[first_letter]
230            if !info.is_good {
231                if let Some(pats) = self.data.excludes_data.get(fl_str.as_str()) {
232                    for &pat in pats {
233                        if self.is_match_cached(pat, prepared) {
234                            info.is_good = true;
235                            info.excuse.push(Box::from(pat));
236                            break;
237                        }
238                    }
239                }
240            }
241        }
242
243        info
244    }
245
246    fn check_word_impl_fast(&self, prepared: &str) -> bool {
247        // Fast path: only check if word is good, no detailed info
248        let fl_str = String::from(prepared.chars().next().map(|c| c.to_string()).unwrap_or_default());
249
250        // 1) Accuse stage: FOUL_DATA[first_letter]
251        if let Some(pats) = self.data.foul_data.get(fl_str.as_str()) {
252            for &pat in pats {
253                if self.is_match_cached(pat, prepared) {
254                    return false;
255                }
256            }
257        }
258
259        // 2) If still good → check FOUL_CORE
260        for (&_key, &pat) in self.data.foul_core.iter() {
261            if self.is_match_cached(pat, prepared) {
262                return false;
263            }
264        }
265
266        // 3) If still good → check BAD_SEMI_PHRASES
267        for &pat in self.data.bad_semi_phrases.iter() {
268            if self.is_match_cached(pat, prepared) {
269                return false;
270            }
271        }
272
273        // 4) Excuse stage: if already accused, check exceptions
274        // EXCLUDES_CORE
275        for (&_key, &pat) in self.data.excludes_core.iter() {
276            if self.is_match_cached(pat, prepared) {
277                return true;
278            }
279        }
280        // EXCLUDES_DATA[first_letter]
281        if let Some(pats) = self.data.excludes_data.get(fl_str.as_str()) {
282            for &pat in pats {
283                if self.is_match_cached(pat, prepared) {
284                    return true;
285                }
286            }
287        }
288
289        false // bad word
290    }
291
292    /// returns replaced line plus counts
293    pub fn clean_line(&self, line: &str) -> CleanLineResult {
294        // Mutable working buffer that accumulates changes
295        let mut out = line.to_string();
296
297        // Counters and diagnostics
298        let mut bad_words = 0usize;
299        let mut bad_phrases = 0usize;
300        let mut detected_words: Vec<Box<str>> = Vec::with_capacity(5);
301        let mut detected_pats = Vec::with_capacity(5);
302
303        // 1) Word-by-word replacement (first hit per surface word):
304        //
305        // - Split the *original* line into tokens according to language rules.
306        // - For each token, normalize and check with accuse/excuse logic.
307        // - If bad, replace the *first* occurrence of the exact surface token in `out`.
308        //   This preserves original casing/punctuation and mirrors your Python behavior.
309        for word in self.split_line(line) {
310            let prepared = self.prepare_word(word.clone());
311            let info = self.check_word_impl(&prepared);
312            if !info.is_good {
313                bad_words += 1;
314                out = out.replacen(&word, self.data.beep, 1);
315                detected_words.push(Box::from(word.as_str()));
316                if let Some(p) = info.accuse.get(0) {
317                    detected_pats.push(p.clone());
318                }
319            }
320        }
321
322        // 2) Phrase-level replacements:
323        //
324        // - BAD_SEMI_PHRASES are broad patterns that run over the whole string.
325        // - We first check via `is_match_cached` to avoid unnecessary work,
326        //   then call `replace_all_cached` which compiles via the same cache.
327        for &pat in self.data.bad_semi_phrases.iter() {
328            if let Some(new_out) = self.replace_all_cached(pat, &out, self.data.beep) {
329                bad_phrases += 1;
330                detected_pats.push(Box::from(pat));
331                out = new_out;
332            }
333        }
334
335        // If you also maintain BAD_PHRASES, process them the same way:
336        for &pat in self.data.bad_phrases.iter() {
337            if let Some(new_out) = self.replace_all_cached(pat, &out, self.data.beep) {
338                bad_phrases += 1;
339                detected_pats.push(Box::from(pat));
340                out = new_out;
341            }
342        }
343
344        CleanLineResult {
345            line: out,
346            bad_words_count: bad_words,
347            bad_phrases_count: bad_phrases,
348            detected_bad_words: detected_words,
349            detected_patterns: detected_pats,
350        }
351    }
352
353    /// Clean an HTML string while preserving tags and replacing bad words with `beep_html`.
354    /// @TODO: Rewrite the implementation, so it'll work with any HTML tags (incl broken etc).
355    /// Use a proper HTML parser like scraper or kuchiki
356    pub fn clean_html_line(&self, line: &str) -> CleanHtmlResult {
357        use crate::html::{tokenize_html, TokType, Token};
358
359        let tokens = tokenize_html(line);
360
361        let mut current_word = String::new();            // plain word (no tags)
362        let mut current_tagged = String::new();          // word with tags as text
363        let mut tagged_list: Vec<&Token> = Vec::new();   // token objects for pre/post reconstruction
364
365        let mut out = String::new();
366        let mut bad_count = 0usize;
367
368        let beep_html = self.data.beep_html; // HTML replacement for a bad word
369
370        // Compute "pre" (opening + self-closing tags) and "post" (closing tags)
371        // from the tokens collected for the current word.
372        fn get_remained_tokens(tagged: &[&Token]) -> (String, String) {
373            let mut pre = String::new();
374            let mut post = String::new();
375
376            for t in tagged {
377                match t.kind {
378                    TokType::TagOpen | TokType::TagSelf => {
379                        // opening/self tags should remain before the censored placeholder
380                        pre.push_str(&t.value);
381                    }
382                    TokType::TagClose => {
383                        // closing tags should remain after the censored placeholder
384                        post.push_str(&t.value);
385                    }
386                    _ => {}
387                }
388            }
389            (pre, post)
390        }
391
392        // Flush the currently accumulated word (and its tag list) into `out`.
393        // If the word is bad, we output `pre + beep_html + post`. Otherwise, we output the original tagged text.
394        // Optionally append a trailing literal (space/spacer) after flushing.
395        let process_spacer = |cw: &mut String,
396                                  ctw: &mut String,
397                                  twl: &mut Vec<&Token>,
398                                  r: &mut String,
399                                  bwc: &mut usize,
400                                  tok: Option<&Token>| {
401            if !cw.is_empty() {
402                // println!("{}", cw);
403                if !self.is_word_good(cw) {
404                    let (pre, post) = get_remained_tokens(twl);
405                    *r += &pre;
406                    *r += beep_html;
407                    *r += &post;
408                    *bwc += 1;
409                } else {
410                    // Good word: emit the original tagged fragment unchanged
411                    *r += ctw;
412                }
413            }
414            // Reset per-word buffers
415            twl.clear();
416            cw.clear();
417            ctw.clear();
418
419            // Append trailing boundary (space/spacer) if provided
420            if let Some(t) = tok {
421                *r += &t.value;
422            }
423        };
424
425        // Iterate over tokens exactly like the Python version
426        for tok in &tokens {
427            match tok.kind {
428                TokType::TagOpen | TokType::TagClose | TokType::TagSelf => {
429                    // Tags are part of the current "tagged word"; they do NOT trigger a flush
430                    tagged_list.push(tok);
431                    current_tagged.push_str(&tok.value);
432                }
433                TokType::Word => {
434                    // Word fragments are appended to both plain and tagged buffers
435                    // println!("current_word: {}", current_word);
436                    if !self.is_word_good(&current_word) {
437                        process_spacer(
438                            &mut current_word,
439                            &mut current_tagged,
440                            &mut tagged_list,
441                            &mut out,
442                            &mut bad_count,
443                            Some(tok),
444                        );
445                    } else {
446                        tagged_list.push(tok);
447                        current_tagged.push_str(&tok.value);
448                        current_word.push_str(&tok.value);
449                    }
450                }
451                TokType::Space |  TokType::Spacer => {
452                    // Boundary: process the current word and then append the space/spacer
453                    process_spacer(
454                        &mut current_word,
455                        &mut current_tagged,
456                        &mut tagged_list,
457                        &mut out,
458                        &mut bad_count,
459                        Some(tok),
460                    );
461                }
462            }
463        }
464
465        // Final flush if the line ended without a trailing space
466        if !current_word.is_empty() || !current_tagged.is_empty() {
467            process_spacer(
468                &mut current_word,
469                &mut current_tagged,
470                &mut tagged_list,
471                &mut out,
472                &mut bad_count,
473                None,
474            );
475        }
476
477        CleanHtmlResult { line: out, bad_words_count: bad_count }
478    }
479}