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