use pinyin::Pinyin;
use std::collections::HashMap;
pub struct VariantDetector {
pinyin_map: HashMap<String, Vec<String>>, shape_map: HashMap<char, Vec<char>>, char_to_pinyin: HashMap<char, String>, }
impl std::fmt::Debug for VariantDetector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VariantDetector")
.field("pinyin_map_size", &self.pinyin_map.len())
.field("shape_map_size", &self.shape_map.len())
.field("char_to_pinyin_size", &self.char_to_pinyin.len())
.finish()
}
}
impl Default for VariantDetector {
fn default() -> Self {
Self::new()
}
}
impl VariantDetector {
pub fn new() -> Self {
VariantDetector {
pinyin_map: HashMap::new(),
shape_map: Self::build_shape_map(),
char_to_pinyin: HashMap::new(),
}
}
pub fn add_word(&mut self, word: &str) {
let chars_result = Pinyin::chars(word).with_tone_style(pinyin::ToneStyle::None);
let han_chars: Vec<char> = word.chars().filter(|c| !c.is_ascii()).collect();
let pinyin_lookup: HashMap<char, String> = han_chars.into_iter().zip(chars_result.iter()).collect();
let pinyins: Vec<String> = word
.chars()
.filter_map(|c| {
pinyin_lookup.get(&c).map(|pinyin| {
self.char_to_pinyin.insert(c, pinyin.clone());
pinyin.clone()
})
})
.collect();
if !pinyins.is_empty() {
let pinyin_key = pinyins.join("");
self.pinyin_map.entry(pinyin_key).or_default().push(word.to_string());
}
}
pub fn detect<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
let mut variants = Vec::new();
variants.extend(self.detect_pinyin_variants(text, original_words));
variants.extend(self.detect_shape_variants(text, original_words));
variants.sort_unstable();
variants.dedup();
variants
}
fn detect_pinyin_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
let text_pinyin = self.text_to_pinyin(text);
original_words
.iter()
.filter(|&&word| {
let word_pinyin: String = word
.chars()
.map(|c| {
self.char_to_pinyin.get(&c).cloned().unwrap_or_else(|| c.to_string())
})
.collect();
text_pinyin.contains(&word_pinyin)
})
.copied()
.collect()
}
fn text_to_pinyin(&self, text: &str) -> String {
let uncached: Vec<char> =
text.chars().filter(|c| !c.is_ascii() && !self.char_to_pinyin.contains_key(c)).collect();
let extra: HashMap<char, String> = if uncached.is_empty() {
HashMap::new()
} else {
let uncached_str: String = uncached.iter().collect();
uncached
.into_iter()
.zip(Pinyin::chars(&uncached_str).with_tone_style(pinyin::ToneStyle::None).iter())
.collect()
};
text.chars()
.map(|c| {
self.char_to_pinyin.get(&c).cloned().or_else(|| extra.get(&c).cloned()).unwrap_or_else(|| c.to_string())
})
.collect()
}
fn detect_shape_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
original_words.iter().filter(|&&word| self.is_shape_variant(text, word)).copied().collect()
}
fn is_shape_variant(&self, text: &str, word: &str) -> bool {
let text_chars: Vec<char> = text.chars().collect();
let word_chars: Vec<char> = word.chars().collect();
if text_chars.len() != word_chars.len() {
return false;
}
text_chars
.iter()
.zip(word_chars.iter())
.all(|(&tc, &wc)| tc == wc || self.shape_map.get(&wc).is_some_and(|variants| variants.contains(&tc)))
}
fn build_shape_map() -> HashMap<char, Vec<char>> {
let mut map: HashMap<char, Vec<char>> = HashMap::new();
for line in include_str!("../../dict/shape_map.txt").lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key_part, vals_part)) = line.split_once(':') else { continue };
let Some(key) = key_part.trim().chars().next() else { continue };
let group: Vec<char> =
std::iter::once(key).chain(vals_part.split(',').filter_map(|s| s.trim().chars().next())).collect();
if group.len() < 2 {
continue;
}
for &c in &group {
let others: Vec<char> = group.iter().filter(|&&x| x != c).copied().collect();
map.entry(c).or_default().extend(others);
}
}
for vals in map.values_mut() {
vals.sort_unstable();
vals.dedup();
}
map
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pinyin_detection() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("dubo", &["赌博"]);
assert_eq!(results, vec!["赌博"]);
}
#[test]
fn test_pinyin_no_match() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("hello", &["赌博"]);
assert!(results.is_empty());
}
#[test]
fn test_shape_variant_detection() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("睹博", &["赌博"]);
assert_eq!(results, vec!["赌博"]);
}
#[test]
fn test_shape_no_match_different_length() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("赌", &["赌博"]);
assert!(results.is_empty()); }
#[test]
fn test_shape_map_loaded_from_file() {
let vd = VariantDetector::new();
assert!(vd.shape_map.len() >= 50, "shape_map has {} entries, expected >= 50", vd.shape_map.len());
assert!(vd.shape_map.get(&'赌').is_some_and(|v| v.contains(&'睹')));
assert!(vd.shape_map.get(&'博').is_some_and(|v| v.contains(&'膊')));
}
#[test]
fn test_shape_variant_bidirectional() {
let mut vd = VariantDetector::new();
vd.add_word("睹博"); assert_eq!(vd.detect("赌博", &["睹博"]), vec!["睹博"]);
}
#[test]
fn test_shape_group_full_symmetry() {
let mut vd = VariantDetector::new();
vd.add_word("人");
assert_eq!(vd.detect("入", &["人"]), vec!["人"]);
assert_eq!(vd.detect("八", &["人"]), vec!["人"]);
}
#[test]
fn test_empty_input() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("", &["赌博"]);
assert!(results.is_empty());
}
#[test]
fn test_all_ascii_input() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("hello world", &["赌博"]);
assert!(results.is_empty());
}
#[test]
fn test_mixed_script() {
let mut vd = VariantDetector::new();
vd.add_word("测试");
let results = vd.detect("这是 test 内容", &["测试"]);
assert!(results.is_empty());
}
#[test]
fn test_multiple_words() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
vd.add_word("色情");
let results = vd.detect("dubo and seqing", &["赌博", "色情"]);
assert_eq!(results.len(), 2);
}
#[test]
fn test_detect_dedup() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let results = vd.detect("睹博", &["赌博"]);
assert_eq!(results.len(), 1);
assert_eq!(results, vec!["赌博"]);
}
#[test]
fn test_detect_returns_borrowed_slices() {
let mut vd = VariantDetector::new();
vd.add_word("赌博");
let words = ["赌博"];
let results = vd.detect("dubo", &words);
assert!(results.iter().all(|r| words.contains(r)));
}
}