Skip to main content

better_profanity/
censor.rs

1//! `Profanity._hide_swear_words`, step for step.
2
3use std::collections::VecDeque;
4use std::ops::Range;
5
6use crate::{CharSet, Walk, WordSet};
7
8/// Python's `str.lower()` for strings with at least one non-ASCII character.
9///
10/// Python lowercases every character on its own, except that `Σ` becomes `ς` at the end of
11/// a word, and its tables follow the Unicode version of the interpreter.
12pub trait Lowercase {
13    /// Appends the lowercase form of `piece` to `out`.
14    fn lower(&mut self, piece: &[char], out: &mut Vec<char>);
15}
16
17/// Lowercasing with Rust's Unicode tables, which apply the same rules as Python.
18#[derive(Clone, Copy, Debug, Default)]
19pub struct StdLowercase;
20
21impl Lowercase for StdLowercase {
22    fn lower(&mut self, piece: &[char], out: &mut Vec<char>) {
23        let s: String = piece.iter().collect();
24        out.extend(s.to_lowercase().chars());
25    }
26}
27
28/// What `_hide_swear_words` reads besides its arguments.
29#[derive(Clone, Copy, Debug)]
30pub struct Config<'a> {
31    /// `CENSOR_WORDSET`.
32    pub words: &'a WordSet,
33    /// The module-level `ALLOWED_CHARACTERS`, which splits the text into words.
34    pub allowed: &'a CharSet,
35    /// `self.ALLOWED_CHARACTERS`, which the helpers that look ahead for the next words use.
36    pub helper_allowed: &'a CharSet,
37    /// `MAX_NUMBER_COMBINATIONS`, at least 1.
38    pub max_number_combinations: usize,
39}
40
41/// An entry pair of `next_words_indices`: a following word, and the same word with the
42/// separators before it. Both end at `end_index`. `empty` marks the `("", index)` pair
43/// that stands for "no more words".
44#[derive(Clone, Debug)]
45struct Pair {
46    sep_start: usize,
47    word: Range<usize>,
48    end_index: usize,
49    empty: bool,
50}
51
52/// Censors `text` as better_profanity's `_hide_swear_words(text, censor_char)` does.
53///
54/// Returns `None` where better_profanity returns `text` itself because it has no word to
55/// check.
56pub fn hide_swear_words<L: Lowercase + ?Sized>(
57    text: &str,
58    censor_char: &str,
59    config: &Config<'_>,
60    lower: &mut L,
61) -> Option<String> {
62    let all: Vec<char> = text.chars().collect();
63    let start = start_of_next_word(&all, 0, config.helper_allowed);
64    if start + 1 >= all.len() {
65        return None;
66    }
67    let mut out = String::with_capacity(text.len() + 8);
68    out.extend(&all[..start]);
69    let mut scanner = Scanner {
70        text: &all[start..],
71        config,
72        lower,
73        buf: Vec::new(),
74        replacement: censor_char.repeat(4),
75    };
76    scanner.run(&mut out);
77    Some(out)
78}
79
80fn start_of_next_word(text: &[char], from: usize, allowed: &CharSet) -> usize {
81    (from..text.len())
82        .find(|&i| allowed.contains(text[i]))
83        .unwrap_or(text.len())
84}
85
86struct Scanner<'t, 'c, L: ?Sized> {
87    text: &'t [char],
88    config: &'c Config<'c>,
89    lower: &'c mut L,
90    buf: Vec<char>,
91    replacement: String,
92}
93
94impl<L: Lowercase + ?Sized> Scanner<'_, '_, L> {
95    fn run(&mut self, out: &mut String) {
96        let text = self.text;
97        let n = text.len();
98        let mut cur = 0..0;
99        let mut skip_index: isize = -1;
100        let mut window: VecDeque<Pair> = VecDeque::new();
101        let (mut whole, mut full, mut separated) =
102            (Walk::default(), Walk::default(), Walk::default());
103
104        for (index, &c) in text.iter().enumerate() {
105            if (index as isize) < skip_index {
106                continue;
107            }
108            if self.config.allowed.contains(c) {
109                if cur.is_empty() {
110                    cur = index..index;
111                }
112                cur.end = index + 1;
113                continue;
114            }
115            if self.config.allowed.strips_to_empty(&text[cur.clone()]) {
116                out.push(c);
117                cur = 0..0;
118                continue;
119            }
120
121            self.update_next_words(&mut window, index);
122            self.walk(&mut whole, cur.clone());
123            match self.any_next_words_form_swear_word(&whole, &window, &mut full, &mut separated) {
124                Some(end_index) => {
125                    out.push_str(&self.replacement);
126                    skip_index = end_index as isize;
127                    window.clear();
128                }
129                None => {
130                    if self.config.words.accepts(&whole) {
131                        out.push_str(&self.replacement);
132                    } else {
133                        out.extend(&text[cur.clone()]);
134                    }
135                    out.push(c);
136                }
137            }
138            cur = 0..0;
139        }
140
141        if !cur.is_empty() && skip_index < n as isize - 1 {
142            self.walk(&mut whole, cur.clone());
143            if self.config.words.accepts(&whole) {
144                out.push_str(&self.replacement);
145            } else {
146                out.extend(&text[cur]);
147            }
148        }
149    }
150
151    /// `_update_next_words_indices`.
152    fn update_next_words(&self, window: &mut VecDeque<Pair>, start_idx: usize) {
153        if window.is_empty() {
154            self.get_next_words(window, start_idx, self.config.max_number_combinations);
155        } else {
156            window.pop_front();
157            if let Some(last) = window.back() {
158                if !last.empty {
159                    self.get_next_words(window, last.end_index, 1);
160                }
161            }
162        }
163    }
164
165    /// `_get_next_words`, appending to `window`.
166    fn get_next_words(&self, window: &mut VecDeque<Pair>, start_idx: usize, count: usize) {
167        let text = self.text;
168        let allowed = self.config.helper_allowed;
169        let mut from = start_idx;
170        for remaining in (1..=count.max(1)).rev() {
171            let start = start_of_next_word(text, from, allowed);
172            if start + 1 >= text.len() {
173                window.push_back(Pair {
174                    sep_start: start,
175                    word: start..start,
176                    end_index: start,
177                    empty: true,
178                });
179                return;
180            }
181            // `_get_next_word_and_end_index`: the index of the first character after the
182            // word, or of the word's last character if it ends the text.
183            let (word_end, end_index) =
184                match (start..text.len()).find(|&i| !allowed.contains(text[i])) {
185                    Some(i) => (i, i),
186                    None => (text.len(), text.len() - 1),
187                };
188            window.push_back(Pair {
189                sep_start: from,
190                word: start..word_end,
191                end_index,
192                empty: false,
193            });
194            if remaining == 1 {
195                return;
196            }
197            from = end_index;
198        }
199    }
200
201    /// `any_next_words_form_swear_word`, given the walk over `cur_word.lower()`.
202    fn any_next_words_form_swear_word(
203        &mut self,
204        whole: &Walk,
205        window: &VecDeque<Pair>,
206        full: &mut Walk,
207        separated: &mut Walk,
208    ) -> Option<usize> {
209        if whole.is_dead() {
210            return None;
211        }
212        full.copy_from(whole);
213        separated.copy_from(whole);
214        for pair in window {
215            if pair.empty {
216                continue;
217            }
218            self.feed(full, pair.word.clone());
219            self.feed(separated, pair.sep_start..pair.word.end);
220            let words = self.config.words;
221            if words.accepts(full) || words.accepts(separated) {
222                return Some(pair.end_index);
223            }
224            if full.is_dead() && separated.is_dead() {
225                return None;
226            }
227        }
228        None
229    }
230
231    /// Starts `walk` over `text[range].lower()`.
232    fn walk(&mut self, walk: &mut Walk, range: Range<usize>) {
233        self.config.words.reset(walk);
234        self.feed(walk, range);
235    }
236
237    /// Continues `walk` with `text[range].lower()`.
238    fn feed(&mut self, walk: &mut Walk, range: Range<usize>) {
239        if walk.is_dead() {
240            return;
241        }
242        let words = self.config.words;
243        let piece = &self.text[range];
244        if piece.iter().all(char::is_ascii) {
245            for &c in piece {
246                words.step(walk, c.to_ascii_lowercase());
247                if walk.is_dead() {
248                    return;
249                }
250            }
251            return;
252        }
253        self.buf.clear();
254        self.lower.lower(piece, &mut self.buf);
255        for &c in &self.buf {
256            words.step(walk, c);
257            if walk.is_dead() {
258                return;
259            }
260        }
261    }
262}