sensitive_rs/variant/
mod.rs1use pinyin::Pinyin;
12use std::collections::HashMap;
13
14pub struct VariantDetector {
16 pinyin_map: HashMap<String, Vec<String>>, shape_map: HashMap<char, Vec<char>>, char_to_pinyin: HashMap<char, String>, }
20
21impl std::fmt::Debug for VariantDetector {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 f.debug_struct("VariantDetector")
24 .field("pinyin_map_size", &self.pinyin_map.len())
25 .field("shape_map_size", &self.shape_map.len())
26 .field("char_to_pinyin_size", &self.char_to_pinyin.len())
27 .finish()
28 }
29}
30
31impl Default for VariantDetector {
32 fn default() -> Self {
33 Self::new()
34 }
35}
36
37impl VariantDetector {
38 pub fn new() -> Self {
53 VariantDetector {
54 pinyin_map: HashMap::new(),
55 shape_map: Self::build_shape_map(),
56 char_to_pinyin: HashMap::new(),
57 }
58 }
59
60 pub fn add_word(&mut self, word: &str) {
62 let chars_result = Pinyin::chars(word).with_tone_style(pinyin::ToneStyle::None);
63 let han_chars: Vec<char> = word.chars().filter(|c| !c.is_ascii()).collect();
64 let pinyin_lookup: HashMap<char, String> = han_chars.into_iter().zip(chars_result.iter()).collect();
65
66 let pinyins: Vec<String> = word
67 .chars()
68 .filter_map(|c| {
69 pinyin_lookup.get(&c).map(|pinyin| {
70 self.char_to_pinyin.insert(c, pinyin.clone());
71 pinyin.clone()
72 })
73 })
74 .collect();
75
76 if !pinyins.is_empty() {
77 let pinyin_key = pinyins.join("");
78 self.pinyin_map.entry(pinyin_key).or_default().push(word.to_string());
79 }
80 }
81
82 pub fn detect<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
98 let mut variants = Vec::new();
99
100 variants.extend(self.detect_pinyin_variants(text, original_words));
102
103 variants.extend(self.detect_shape_variants(text, original_words));
105
106 variants.sort_unstable();
107 variants.dedup();
108 variants
109 }
110
111 fn detect_pinyin_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
113 let text_pinyin = self.text_to_pinyin(text);
114
115 original_words
116 .iter()
117 .filter(|&&word| {
118 let word_pinyin: String = word
120 .chars()
121 .map(|c| {
122 self.char_to_pinyin.get(&c).cloned().unwrap_or_else(|| c.to_string())
123 })
125 .collect();
126
127 text_pinyin.contains(&word_pinyin)
128 })
129 .copied()
130 .collect()
131 }
132
133 fn text_to_pinyin(&self, text: &str) -> String {
135 let uncached: Vec<char> =
137 text.chars().filter(|c| !c.is_ascii() && !self.char_to_pinyin.contains_key(c)).collect();
138 let extra: HashMap<char, String> = if uncached.is_empty() {
139 HashMap::new()
140 } else {
141 let uncached_str: String = uncached.iter().collect();
142 uncached
143 .into_iter()
144 .zip(Pinyin::chars(&uncached_str).with_tone_style(pinyin::ToneStyle::None).iter())
145 .collect()
146 };
147
148 text.chars()
149 .map(|c| {
150 self.char_to_pinyin.get(&c).cloned().or_else(|| extra.get(&c).cloned()).unwrap_or_else(|| c.to_string())
151 })
152 .collect()
153 }
154
155 fn detect_shape_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
157 original_words.iter().filter(|&&word| self.is_shape_variant(text, word)).copied().collect()
158 }
159
160 fn is_shape_variant(&self, text: &str, word: &str) -> bool {
162 let text_chars: Vec<char> = text.chars().collect();
163 let word_chars: Vec<char> = word.chars().collect();
164
165 if text_chars.len() != word_chars.len() {
166 return false;
167 }
168
169 text_chars
170 .iter()
171 .zip(word_chars.iter())
172 .all(|(&tc, &wc)| tc == wc || self.shape_map.get(&wc).is_some_and(|variants| variants.contains(&tc)))
173 }
174
175 fn build_shape_map() -> HashMap<char, Vec<char>> {
182 let mut map: HashMap<char, Vec<char>> = HashMap::new();
183
184 for line in include_str!("../../dict/shape_map.txt").lines() {
185 let line = line.trim();
186 if line.is_empty() || line.starts_with('#') {
187 continue;
188 }
189
190 let Some((key_part, vals_part)) = line.split_once(':') else { continue };
192 let Some(key) = key_part.trim().chars().next() else { continue };
193 let group: Vec<char> =
194 std::iter::once(key).chain(vals_part.split(',').filter_map(|s| s.trim().chars().next())).collect();
195 if group.len() < 2 {
196 continue;
197 }
198
199 for &c in &group {
201 let others: Vec<char> = group.iter().filter(|&&x| x != c).copied().collect();
202 map.entry(c).or_default().extend(others);
203 }
204 }
205
206 for vals in map.values_mut() {
208 vals.sort_unstable();
209 vals.dedup();
210 }
211 map
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_pinyin_detection() {
221 let mut vd = VariantDetector::new();
222 vd.add_word("赌博");
223 let results = vd.detect("dubo", &["赌博"]);
224 assert_eq!(results, vec!["赌博"]);
225 }
226
227 #[test]
228 fn test_pinyin_no_match() {
229 let mut vd = VariantDetector::new();
230 vd.add_word("赌博");
231 let results = vd.detect("hello", &["赌博"]);
232 assert!(results.is_empty());
233 }
234
235 #[test]
236 fn test_shape_variant_detection() {
237 let mut vd = VariantDetector::new();
238 vd.add_word("赌博");
239 let results = vd.detect("睹博", &["赌博"]);
241 assert_eq!(results, vec!["赌博"]);
242 }
243
244 #[test]
245 fn test_shape_no_match_different_length() {
246 let mut vd = VariantDetector::new();
247 vd.add_word("赌博");
248 let results = vd.detect("赌", &["赌博"]);
249 assert!(results.is_empty()); }
251
252 #[test]
253 fn test_shape_map_loaded_from_file() {
254 let vd = VariantDetector::new();
256 assert!(vd.shape_map.len() >= 50, "shape_map has {} entries, expected >= 50", vd.shape_map.len());
257 assert!(vd.shape_map.get(&'赌').is_some_and(|v| v.contains(&'睹')));
259 assert!(vd.shape_map.get(&'博').is_some_and(|v| v.contains(&'膊')));
260 }
261
262 #[test]
263 fn test_shape_variant_bidirectional() {
264 let mut vd = VariantDetector::new();
267 vd.add_word("睹博"); assert_eq!(vd.detect("赌博", &["睹博"]), vec!["睹博"]);
269 }
270
271 #[test]
272 fn test_shape_group_full_symmetry() {
273 let mut vd = VariantDetector::new();
275 vd.add_word("人");
276 assert_eq!(vd.detect("入", &["人"]), vec!["人"]);
277 assert_eq!(vd.detect("八", &["人"]), vec!["人"]);
278 }
279
280 #[test]
281 fn test_empty_input() {
282 let mut vd = VariantDetector::new();
283 vd.add_word("赌博");
284 let results = vd.detect("", &["赌博"]);
285 assert!(results.is_empty());
286 }
287
288 #[test]
289 fn test_all_ascii_input() {
290 let mut vd = VariantDetector::new();
291 vd.add_word("赌博");
292 let results = vd.detect("hello world", &["赌博"]);
293 assert!(results.is_empty());
294 }
295
296 #[test]
297 fn test_mixed_script() {
298 let mut vd = VariantDetector::new();
299 vd.add_word("测试");
300 let results = vd.detect("这是 test 内容", &["测试"]);
301 assert!(results.is_empty());
303 }
304
305 #[test]
306 fn test_multiple_words() {
307 let mut vd = VariantDetector::new();
308 vd.add_word("赌博");
309 vd.add_word("色情");
310 let results = vd.detect("dubo and seqing", &["赌博", "色情"]);
311 assert_eq!(results.len(), 2);
312 }
313
314 #[test]
315 fn test_detect_dedup() {
316 let mut vd = VariantDetector::new();
319 vd.add_word("赌博");
320 let results = vd.detect("睹博", &["赌博"]);
321 assert_eq!(results.len(), 1);
322 assert_eq!(results, vec!["赌博"]);
323 }
324
325 #[test]
326 fn test_detect_returns_borrowed_slices() {
327 let mut vd = VariantDetector::new();
330 vd.add_word("赌博");
331 let words = ["赌博"];
332 let results = vd.detect("dubo", &words);
333 assert!(results.iter().all(|r| words.contains(r)));
334 }
335}